app v1
This commit is contained in:
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
18
frontend/package.json
Normal 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
756
frontend/src/App.vue
Normal 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>
|
||||
1196
frontend/src/components/AdminPage.vue
Normal file
1196
frontend/src/components/AdminPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
921
frontend/src/components/BackgroundsManager.vue
Normal file
921
frontend/src/components/BackgroundsManager.vue
Normal 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>
|
||||
2297
frontend/src/components/MediaManager.vue
Normal file
2297
frontend/src/components/MediaManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
1130
frontend/src/components/OpeningsManager.vue
Normal file
1130
frontend/src/components/OpeningsManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal 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
1034
frontend/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/vite.config.js
Normal file
25
frontend/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user