Spaces:
Running
Running
<template> | |
<div class="segmentation-sidebar"> | |
<!-- Navigation header --> | |
<div class="sidebar-header"> | |
<div class="view-navigation"> | |
<button | |
class="nav-button" | |
@click="previousView" | |
> | |
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> | |
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/> | |
</svg> | |
</button> | |
<span class="view-indicator"> | |
{{ currentViewIndex + 1 }} / {{ views.length }} | |
</span> | |
<button | |
class="nav-button" | |
@click="nextView" | |
> | |
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> | |
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/> | |
</svg> | |
</button> | |
</div> | |
</div> | |
<!-- Content area with transitions --> | |
<div class="sidebar-content"> | |
<transition :name="transitionName" mode="out-in"> | |
<div :key="currentViewIndex" class="view-content"> | |
<!-- Page 1: Video Upload and FPS --> | |
<div v-if="currentViewIndex === 0" class="video-upload-page"> | |
<button class="upload-video-btn" @click="uploadVideo"> | |
<span>Upload Video</span> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"> | |
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
<path d="m17 8-5-5-5 5"/> | |
<path d="M12 3v12"/> | |
</svg> | |
</button> | |
<!-- Input file caché pour vidéo --> | |
<input | |
ref="videoFileInput" | |
type="file" | |
accept="video/*" | |
@change="handleVideoUpload" | |
style="display: none;" | |
/> | |
<div class="video-list" v-if="videoStore.videos.length"> | |
<div | |
v-for="video in videoStore.videos" | |
:key="video.path" | |
class="video-item" | |
:class="{ active: videoStore.selectedVideo?.path === video.path }" | |
@click="selectVideo(video)" | |
> | |
{{ video.name }} | |
</div> | |
</div> | |
<!-- Sélecteur de FPS --> | |
<div class="fps-selector"> | |
<label for="fps-select" class="fps-label">FPS prédéfini</label> | |
<select | |
id="fps-select" | |
v-model="selectedFps" | |
@change="updateFps" | |
class="fps-select" | |
> | |
<option value="15">15 FPS</option> | |
<option value="24">24 FPS</option> | |
<option value="25">25 FPS</option> | |
<option value="30">30 FPS</option> | |
<option value="50">50 FPS</option> | |
<option value="60">60 FPS</option> | |
<option value="120">120 FPS</option> | |
</select> | |
<div class="custom-fps"> | |
<label for="custom-fps-input">FPS personnalisé</label> | |
<div class="custom-fps-row"> | |
<input | |
id="custom-fps-input" | |
type="number" | |
min="1" | |
step="0.01" | |
v-model="customFps" | |
@keydown.enter="applyCustomFps" | |
class="fps-input" | |
placeholder="ex: 29.97" | |
/> | |
<button class="apply-fps-btn" @click="applyCustomFps" title="Appliquer" aria-label="Appliquer"> | |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
<path d="M20 6L9 17l-5-5"/> | |
</svg> | |
</button> | |
</div> | |
<p v-if="fpsError" class="fps-error">{{ fpsError }}</p> | |
</div> | |
</div> | |
</div> | |
<!-- Page 2: Config Upload --> | |
<div v-if="currentViewIndex === 1" class="config-upload-page"> | |
<button class="upload-config-btn" @click="uploadConfig"> | |
<span>Upload Config</span> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"> | |
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
<path d="m17 8-5-5-5 5"/> | |
<path d="M12 3v12"/> | |
</svg> | |
</button> | |
<!-- Input file caché pour config --> | |
<input | |
ref="configFileInput" | |
type="file" | |
accept=".json,.yaml,.yml,.txt" | |
@change="handleConfigUpload" | |
style="display: none;" | |
/> | |
<div class="config-info" v-if="uploadedConfig"> | |
<div class="config-item"> | |
<strong>Config loaded:</strong> {{ uploadedConfig.name }} | |
</div> | |
<div class="config-item" v-if="configInfo"> | |
<strong>Objects found:</strong> {{ configInfo.objectCount }} | |
</div> | |
<div class="config-item" v-if="configInfo"> | |
<strong>Frames with annotations:</strong> {{ configInfo.frameCount }} | |
</div> | |
<div class="config-item" v-if="configInfo"> | |
<strong>Total annotations:</strong> {{ configInfo.annotationCount }} | |
</div> | |
<div class="config-actions"> | |
<button | |
class="load-config-btn" | |
@click="loadConfigData" | |
:disabled="!uploadedConfig" | |
> | |
Load Annotations | |
</button> | |
<button | |
class="clear-config-btn" | |
@click="clearConfigData" | |
:disabled="!configInfo" | |
> | |
Clear | |
</button> | |
</div> | |
</div> | |
</div> | |
<!-- Page 3: CSV Analysis --> | |
<div v-if="currentViewIndex === 2" class="csv-analysis-page"> | |
<button class="upload-csv-btn" @click="uploadCSV"> | |
<span>Upload Event CSV</span> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"> | |
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
<path d="m17 8-5-5-5 5"/> | |
<path d="M12 3v12"/> | |
</svg> | |
</button> | |
<!-- Input file caché pour CSV --> | |
<input | |
ref="csvFileInput" | |
type="file" | |
accept=".csv" | |
@change="handleCSVUpload" | |
style="display: none;" | |
/> | |
<div class="csv-controls" v-if="uploadedCSV && csvColumns.length"> | |
<div class="control-group"> | |
<label>Row Column:</label> | |
<select v-model="selectedRowColumn" @change="updateFilteredTimestamps"> | |
<option v-for="column in csvColumns" :key="column" :value="column"> | |
{{ column }} | |
</option> | |
</select> | |
</div> | |
<div class="control-group"> | |
<label>Timestamp Column:</label> | |
<select v-model="selectedTimestampColumn" @change="updateFilteredTimestamps"> | |
<option v-for="column in csvColumns" :key="column" :value="column"> | |
{{ column }} | |
</option> | |
</select> | |
</div> | |
<div class="control-group"> | |
<label>Filter Keyword:</label> | |
<input | |
type="text" | |
v-model="filterKeyword" | |
@input="onFilterInput" | |
@keydown="onFilterKeydown" | |
@keyup="onFilterKeyup" | |
@paste="onFilterPaste" | |
@focus="onFilterFocus" | |
@blur="onFilterBlur" | |
placeholder="Enter keyword to filter" | |
/> | |
</div> | |
</div> | |
<div class="csv-results" v-if="filteredTimestamps.length"> | |
<h4>Filtered Timestamps:</h4> | |
<div class="timestamp-list"> | |
<div | |
v-for="(timestamp, index) in filteredTimestamps" | |
:key="index" | |
class="timestamp-item" | |
@click="gotoTimestamp(timestamp)" | |
> | |
<span class="timestamp-value">{{ timestamp }}</span> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</transition> | |
</div> | |
</div> | |
</template> | |
<script> | |
import { useVideoStore } from '../stores/videoStore' | |
import { useAnnotationStore } from '../stores/annotationStore' | |
export default { | |
name: 'SegmentationSidebar', | |
data() { | |
const videoStore = useVideoStore() | |
const annotationStore = useAnnotationStore() | |
return { | |
videoStore, | |
annotationStore, | |
selectedFps: videoStore.fps || 25, | |
customFps: '', | |
fpsError: '', | |
currentViewIndex: 0, | |
transitionName: 'slide-right', | |
uploadedConfig: null, | |
configInfo: null, | |
// CSV Analysis data | |
uploadedCSV: null, | |
csvData: null, | |
csvColumns: [], | |
selectedRowColumn: 'Row', | |
selectedTimestampColumn: 'Start time', | |
filterKeyword: 'Possession', | |
filteredTimestamps: [], | |
views: [ | |
{ | |
name: 'Video Upload', | |
component: 'VideoUploadView' | |
}, | |
{ | |
name: 'Config Upload', | |
component: 'ConfigUploadView' | |
}, | |
{ | |
name: 'CSV Analysis', | |
component: 'CSVAnalysisView' | |
} | |
] | |
} | |
}, | |
computed: { | |
currentView() { | |
return this.views[this.currentViewIndex] | |
} | |
}, | |
watch: { | |
'videoStore.selectedVideo': { | |
handler(newVideo) { | |
console.log('Vidéo sélectionnée:', newVideo) | |
}, | |
deep: true | |
}, | |
'filterKeyword': { | |
handler() { | |
this.updateFilteredTimestamps() | |
} | |
} | |
}, | |
mounted() { | |
console.log('SegmentationSidebar mounted - prêt pour l\'upload de vidéo') | |
}, | |
methods: { | |
// Navigation methods | |
nextView() { | |
this.transitionName = 'slide-right' | |
this.currentViewIndex = (this.currentViewIndex + 1) % this.views.length | |
}, | |
previousView() { | |
this.transitionName = 'slide-left' | |
this.currentViewIndex = this.currentViewIndex === 0 | |
? this.views.length - 1 | |
: this.currentViewIndex - 1 | |
}, | |
// Video upload methods | |
uploadVideo() { | |
this.$refs.videoFileInput.click() | |
}, | |
handleVideoUpload(event) { | |
const file = event.target.files[0] | |
if (file) { | |
console.log('Vidéo sélectionnée:', file.name) | |
// Créer un URL blob pour la vidéo | |
const videoUrl = URL.createObjectURL(file) | |
// Créer un objet vidéo pour le store | |
const videoObject = { | |
name: file.name, | |
path: videoUrl, | |
file: file, | |
size: file.size, | |
type: file.type | |
} | |
// Mettre à jour le store avec la nouvelle vidéo | |
this.videoStore.setVideos([videoObject]) | |
this.videoStore.setSelectedVideo(videoObject) | |
// Émettre l'événement pour informer les autres composants | |
this.$emit('video-selected', videoObject) | |
console.log('Vidéo uploadée et sélectionnée:', videoObject) | |
} | |
}, | |
selectVideo(video) { | |
this.videoStore.setSelectedVideo(video) | |
this.$emit('video-selected', video) | |
}, | |
updateFps() { | |
const parsed = parseFloat(String(this.selectedFps)) | |
this.videoStore.setFps(parsed) | |
if (this.annotationStore.currentSession) { | |
this.annotationStore.currentSession.frameRate = parsed | |
if (this.annotationStore.currentSession.metadata) { | |
this.annotationStore.currentSession.metadata.fps = parsed | |
} | |
} | |
}, | |
applyCustomFps() { | |
this.fpsError = '' | |
const value = typeof this.customFps === 'string' ? this.customFps.replace(',', '.') : this.customFps | |
const parsed = parseFloat(value) | |
if (isNaN(parsed)) { | |
this.fpsError = 'Veuillez entrer un nombre valide.' | |
return | |
} | |
if (parsed <= 0 || parsed > 1000) { | |
this.fpsError = 'La valeur doit être > 0 et raisonnable (< 1000).' | |
return | |
} | |
this.videoStore.setFps(parsed) | |
this.selectedFps = String(parsed) | |
if (this.annotationStore.currentSession) { | |
this.annotationStore.currentSession.frameRate = parsed | |
if (this.annotationStore.currentSession.metadata) { | |
this.annotationStore.currentSession.metadata.fps = parsed | |
} | |
} | |
}, | |
// Config upload methods | |
uploadConfig() { | |
this.$refs.configFileInput.click() | |
}, | |
handleConfigUpload(event) { | |
const file = event.target.files[0] | |
if (file) { | |
console.log('Config sélectionnée:', file.name) | |
// Lire le contenu du fichier | |
const reader = new FileReader() | |
reader.onload = (e) => { | |
try { | |
const configContent = e.target.result | |
this.uploadedConfig = { | |
name: file.name, | |
content: configContent, | |
size: file.size, | |
type: file.type | |
} | |
// Parser le contenu pour extraire les informations | |
this.parseConfigContent(configContent) | |
// Émettre l'événement pour informer les autres composants | |
this.$emit('config-uploaded', this.uploadedConfig) | |
console.log('Config uploadée:', this.uploadedConfig) | |
} catch (error) { | |
console.error('Erreur lors de la lecture du fichier config:', error) | |
} | |
} | |
reader.readAsText(file) | |
} | |
}, | |
parseConfigContent(content) { | |
try { | |
let configData | |
// Essayer de parser comme JSON | |
if (content.trim().startsWith('{') || content.trim().startsWith('[')) { | |
configData = JSON.parse(content) | |
} else { | |
// Essayer de parser comme YAML (format simple) | |
configData = this.parseYamlLike(content) | |
} | |
// Analyser les données pour extraire les informations | |
this.configInfo = this.analyzeConfigData(configData) | |
console.log('Config parsée:', configData) | |
console.log('Informations extraites:', this.configInfo) | |
} catch (error) { | |
console.error('Erreur lors du parsing du fichier config:', error) | |
this.configInfo = null | |
} | |
}, | |
parseYamlLike(content) { | |
// Parser simple pour les fichiers YAML-like | |
const lines = content.split('\n') | |
const result = {} | |
let currentSection = null | |
for (const line of lines) { | |
const trimmedLine = line.trim() | |
if (!trimmedLine || trimmedLine.startsWith('#')) continue | |
if (trimmedLine.endsWith(':')) { | |
currentSection = trimmedLine.slice(0, -1) | |
result[currentSection] = {} | |
} else if (currentSection && trimmedLine.includes(':')) { | |
const [key, value] = trimmedLine.split(':', 2) | |
result[currentSection][key.trim()] = value.trim() | |
} | |
} | |
return result | |
}, | |
analyzeConfigData(data) { | |
let objectCount = 0 | |
let frameCount = 0 | |
let annotationCount = 0 | |
// Analyser les objets | |
if (data.objects) { | |
objectCount = Object.keys(data.objects).length | |
} | |
// Analyser les annotations initiales par frame | |
if (data.initial_annotations) { | |
frameCount = Object.keys(data.initial_annotations).length | |
annotationCount = Object.values(data.initial_annotations).reduce((total, frameAnnotations) => { | |
return total + (Array.isArray(frameAnnotations) ? frameAnnotations.length : 0) | |
}, 0) | |
} | |
return { | |
objectCount, | |
frameCount, | |
annotationCount, | |
rawData: data | |
} | |
}, | |
loadConfigData() { | |
if (!this.configInfo || !this.configInfo.rawData) { | |
console.error('Aucune donnée de configuration à charger') | |
return | |
} | |
try { | |
const data = this.configInfo.rawData | |
// Charger les objets | |
if (data.objects) { | |
this.annotationStore.objects = { ...data.objects } | |
console.log('Objets chargés:', this.annotationStore.objects) | |
} | |
// Charger les annotations initiales | |
if (data.initial_annotations) { | |
this.annotationStore.frameAnnotations = { ...data.initial_annotations } | |
console.log('Annotations initiales chargées:', this.annotationStore.frameAnnotations) | |
} | |
// Mettre à jour les métadonnées si disponibles | |
if (data.metadata) { | |
this.annotationStore.updateVideoMetadata(data.metadata) | |
console.log('Métadonnées mises à jour:', data.metadata) | |
} | |
// Mettre à jour le compteur d'objets | |
if (data.objects) { | |
const maxId = Math.max(...Object.keys(data.objects).map(id => parseInt(id)), 0) | |
this.annotationStore.objectIdCounter = maxId + 1 | |
} | |
console.log('Configuration chargée avec succès!') | |
this.$emit('config-loaded', data) | |
} catch (error) { | |
console.error('Erreur lors du chargement de la configuration:', error) | |
} | |
}, | |
clearConfigData() { | |
this.uploadedConfig = null | |
this.configInfo = null | |
console.log('Données de configuration effacées') | |
}, | |
// CSV upload and analysis methods | |
uploadCSV() { | |
this.$refs.csvFileInput.click() | |
}, | |
// Filter event handlers | |
onFilterInput() { | |
this.updateFilteredTimestamps() | |
}, | |
onFilterKeydown(event) { | |
// Empêcher la propagation pour éviter les conflits avec d'autres raccourcis | |
event.stopPropagation() | |
}, | |
onFilterKeyup(event) { | |
// Empêcher la propagation pour éviter les conflits avec d'autres raccourcis | |
event.stopPropagation() | |
this.updateFilteredTimestamps() | |
}, | |
onFilterPaste() { | |
// Laisser le paste se faire naturellement, puis mettre à jour | |
setTimeout(() => { | |
this.updateFilteredTimestamps() | |
}, 0) | |
}, | |
onFilterFocus(event) { | |
// S'assurer que l'input capture tous les événements clavier | |
event.target.setAttribute('data-focused', 'true') | |
}, | |
onFilterBlur(event) { | |
event.target.removeAttribute('data-focused') | |
}, | |
handleCSVUpload(event) { | |
const file = event.target.files[0] | |
if (file) { | |
console.log('CSV sélectionné:', file.name) | |
// Lire le contenu du fichier | |
const reader = new FileReader() | |
reader.onload = (e) => { | |
try { | |
const csvContent = e.target.result | |
this.uploadedCSV = { | |
name: file.name, | |
content: csvContent, | |
size: file.size, | |
type: file.type | |
} | |
// Parser le contenu CSV | |
this.parseCSVContent(csvContent) | |
// Émettre l'événement pour informer les autres composants | |
this.$emit('csv-uploaded', this.uploadedCSV) | |
console.log('CSV uploadé:', this.uploadedCSV) | |
} catch (error) { | |
console.error('Erreur lors de la lecture du fichier CSV:', error) | |
} | |
} | |
reader.readAsText(file) | |
} | |
}, | |
parseCSVContent(content) { | |
try { | |
const lines = content.split('\n').filter(line => line.trim()) | |
if (lines.length === 0) { | |
console.error('Fichier CSV vide') | |
return | |
} | |
// Parser l'en-tête pour obtenir les colonnes | |
const header = lines[0].split(',').map(col => col.trim().replace(/"/g, '')) | |
this.csvColumns = header | |
// Parser les données | |
this.csvData = [] | |
for (let i = 1; i < lines.length; i++) { | |
const values = lines[i].split(',').map(val => val.trim().replace(/"/g, '')) | |
const row = {} | |
header.forEach((col, index) => { | |
row[col] = values[index] || '' | |
}) | |
this.csvData.push(row) | |
} | |
console.log('CSV parsé:', this.csvData) | |
console.log('Colonnes:', this.csvColumns) | |
// Mettre à jour les timestamps filtrés | |
this.updateFilteredTimestamps() | |
} catch (error) { | |
console.error('Erreur lors du parsing du fichier CSV:', error) | |
this.csvData = null | |
this.csvColumns = [] | |
} | |
}, | |
updateFilteredTimestamps() { | |
if (!this.csvData || !this.selectedRowColumn || !this.selectedTimestampColumn) { | |
this.filteredTimestamps = [] | |
return | |
} | |
this.filteredTimestamps = this.csvData | |
.filter(row => { | |
const rowValue = row[this.selectedRowColumn] || '' | |
return rowValue.toLowerCase().includes(this.filterKeyword.toLowerCase()) | |
}) | |
.map(row => row[this.selectedTimestampColumn]) | |
.filter(timestamp => timestamp && timestamp.trim()) | |
.sort((a, b) => { | |
// Convertir les timestamps en secondes pour le tri | |
const secondsA = this.parseTimestamp(a) | |
const secondsB = this.parseTimestamp(b) | |
// Si les deux timestamps sont valides, les trier | |
if (secondsA !== null && secondsB !== null) { | |
return secondsA - secondsB | |
} | |
// Si un seul est valide, le mettre en premier | |
if (secondsA !== null) return -1 | |
if (secondsB !== null) return 1 | |
// Sinon, trier alphabétiquement | |
return a.localeCompare(b) | |
}) | |
}, | |
gotoTimestamp(timestamp) { | |
// Convertir le timestamp en secondes et naviguer vers cette position | |
const seconds = this.parseTimestamp(timestamp) | |
if (seconds !== null) { | |
this.videoStore.setCurrentTime(seconds) | |
} | |
}, | |
parseTimestamp(timestamp) { | |
// Parser différents formats de timestamp | |
if (!timestamp) return null | |
// Format MM:SS ou HH:MM:SS | |
const timeParts = timestamp.split(':') | |
if (timeParts.length === 2) { | |
// Format MM:SS | |
const minutes = parseInt(timeParts[0]) | |
const seconds = parseInt(timeParts[1]) | |
return minutes * 60 + seconds | |
} else if (timeParts.length === 3) { | |
// Format HH:MM:SS | |
const hours = parseInt(timeParts[0]) | |
const minutes = parseInt(timeParts[1]) | |
const seconds = parseInt(timeParts[2]) | |
return hours * 3600 + minutes * 60 + seconds | |
} | |
// Essayer de parser comme nombre de secondes | |
const seconds = parseFloat(timestamp) | |
if (!isNaN(seconds)) { | |
return seconds | |
} | |
return null | |
} | |
} | |
} | |
</script> | |
<style scoped> | |
.segmentation-sidebar { | |
background: #363636; | |
height: 100%; | |
width: 200px; | |
display: flex; | |
flex-direction: column; | |
overflow: hidden; | |
} | |
.sidebar-header { | |
padding: 8px 12px; | |
background: #3c3c3c; | |
border-bottom: 1px solid #4a4a4a; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
.view-navigation { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
} | |
.nav-button { | |
background: #4a4a4a; | |
border: none; | |
border-radius: 4px; | |
color: white; | |
width: 24px; | |
height: 24px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
cursor: pointer; | |
transition: all 0.2s; | |
} | |
.nav-button:hover:not(:disabled) { | |
background: #5a5a5a; | |
} | |
.view-indicator { | |
color: #ccc; | |
font-size: 0.8rem; | |
min-width: 40px; | |
text-align: center; | |
} | |
.sidebar-content { | |
flex: 1; | |
position: relative; | |
overflow: hidden; | |
padding: 16px; | |
} | |
.view-content { | |
position: absolute; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
padding: 16px; | |
display: flex; | |
flex-direction: column; | |
gap: 16px; | |
} | |
/* Video Upload Page Styles */ | |
.video-upload-page { | |
display: flex; | |
flex-direction: column; | |
gap: 16px; | |
height: 100%; | |
} | |
.upload-video-btn { | |
background: #424242; | |
border: none; | |
border-radius: 8px; | |
color: white; | |
padding: 12px 16px; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
width: 100%; | |
font-size: 1rem; | |
flex-shrink: 0; | |
transition: background-color 0.2s ease; | |
} | |
.upload-video-btn:hover { | |
background: #4a4a4a; | |
} | |
.video-list { | |
height: 20vh; | |
background: #424242; | |
border-radius: 8px; | |
overflow-y: auto; | |
display: flex; | |
flex-direction: column; | |
gap: 4px; | |
padding: 4px; | |
} | |
/* Styles pour Firefox */ | |
.video-list { | |
scrollbar-width: thin; | |
scrollbar-color: rgba(255, 255, 255, 0.3) transparent; | |
} | |
/* Styles pour Chrome/Safari/Edge */ | |
.video-list::-webkit-scrollbar { | |
width: 4px; | |
} | |
.video-list::-webkit-scrollbar-track { | |
background: transparent; | |
} | |
.video-list::-webkit-scrollbar-thumb { | |
background: rgba(255, 255, 255, 0.3); | |
border-radius: 2px; | |
} | |
.video-item { | |
padding: 8px 12px; | |
border-radius: 4px; | |
cursor: pointer; | |
color: white; | |
transition: background-color 0.2s; | |
} | |
.video-item:hover { | |
background: #4a4a4a; | |
} | |
.video-item.active { | |
background: #3a3a3a; | |
} | |
.fps-selector { | |
display: flex; | |
flex-direction: column; | |
gap: 8px; | |
padding: 12px; | |
border-radius: 8px; | |
background: #424242; | |
color: white; | |
} | |
.fps-select { | |
background: #363636; | |
border: 1px solid #555; | |
border-radius: 4px; | |
color: white; | |
padding: 8px 12px; | |
font-size: 0.9rem; | |
width: 100%; | |
} | |
.fps-select:focus { | |
outline: none; | |
border-color: #4CAF50; | |
} | |
.custom-fps { | |
display: flex; | |
flex-direction: column; | |
gap: 6px; | |
} | |
.custom-fps-row { | |
display: grid; | |
grid-template-columns: 1fr 36px; | |
gap: 8px; | |
align-items: center; | |
} | |
.fps-input { | |
background: #363636; | |
border: 1px solid #555; | |
border-radius: 4px; | |
color: white; | |
padding: 8px 12px; | |
font-size: 0.9rem; | |
width: 100%; | |
height: 36px; | |
appearance: textfield; | |
-webkit-appearance: none; | |
min-width: 0; | |
} | |
/* Masquer les flèches du type number (Chrome/Edge) */ | |
.fps-input::-webkit-outer-spin-button, | |
.fps-input::-webkit-inner-spin-button { | |
-webkit-appearance: none; | |
margin: 0; | |
} | |
/* Masquer les flèches du type number (Firefox) */ | |
.fps-input[type="number"] { | |
-moz-appearance: textfield; | |
appearance: textfield; | |
} | |
.apply-fps-btn { | |
background: #424242; | |
border: 1px solid #555; | |
border-radius: 4px; | |
color: white; | |
padding: 0; | |
font-size: 0.9rem; | |
cursor: pointer; | |
height: 36px; | |
width: 36px; | |
white-space: nowrap; | |
display: inline-flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.apply-fps-btn:hover { | |
background: #4a4a4a; | |
} | |
.fps-error { | |
color: #ff7675; | |
font-size: 0.8rem; | |
} | |
.fps-label { | |
color: #ccc; | |
font-size: 0.8rem; | |
} | |
/* Config Upload Page Styles */ | |
.config-upload-page { | |
display: flex; | |
flex-direction: column; | |
gap: 16px; | |
height: 100%; | |
} | |
.upload-config-btn { | |
background: #424242; | |
border: none; | |
border-radius: 8px; | |
color: white; | |
padding: 12px 16px; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
width: 100%; | |
font-size: 1rem; | |
flex-shrink: 0; | |
transition: background-color 0.2s ease; | |
} | |
.upload-config-btn:hover { | |
background: #4a4a4a; | |
} | |
.config-info { | |
background: #424242; | |
border-radius: 8px; | |
padding: 12px; | |
color: white; | |
} | |
.config-item { | |
font-size: 0.9rem; | |
margin-bottom: 8px; | |
color: #ccc; | |
} | |
.config-actions { | |
display: flex; | |
flex-direction: column; | |
gap: 8px; | |
margin-top: 12px; | |
} | |
.load-config-btn, .clear-config-btn { | |
background: #4CAF50; | |
border: none; | |
border-radius: 4px; | |
color: white; | |
padding: 8px 12px; | |
cursor: pointer; | |
font-size: 0.9rem; | |
transition: background-color 0.2s ease; | |
} | |
.load-config-btn:hover:not(:disabled) { | |
background: #45a049; | |
} | |
.load-config-btn:disabled { | |
background: #666; | |
cursor: not-allowed; | |
} | |
.clear-config-btn { | |
background: #f44336; | |
} | |
.clear-config-btn:hover:not(:disabled) { | |
background: #da190b; | |
} | |
.clear-config-btn:disabled { | |
background: #666; | |
cursor: not-allowed; | |
} | |
/* CSV Analysis Page Styles */ | |
.csv-analysis-page { | |
display: flex; | |
flex-direction: column; | |
gap: 16px; | |
height: 100%; | |
} | |
.upload-csv-btn { | |
background: #424242; | |
border: none; | |
border-radius: 8px; | |
color: white; | |
padding: 12px 16px; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
width: 100%; | |
font-size: 1rem; | |
flex-shrink: 0; | |
transition: background-color 0.2s ease; | |
} | |
.upload-csv-btn:hover { | |
background: #4a4a4a; | |
} | |
.csv-controls { | |
background: #424242; | |
border-radius: 8px; | |
padding: 12px; | |
display: flex; | |
flex-direction: column; | |
gap: 12px; | |
} | |
.control-group { | |
display: flex; | |
flex-direction: column; | |
gap: 4px; | |
} | |
.control-group label { | |
color: #ccc; | |
font-size: 0.8rem; | |
font-weight: 500; | |
} | |
.control-group select, | |
.control-group input { | |
background: #363636; | |
border: 1px solid #555; | |
border-radius: 4px; | |
color: white; | |
padding: 6px 8px; | |
font-size: 0.9rem; | |
width: 100%; | |
} | |
.control-group select:focus, | |
.control-group input:focus { | |
outline: none; | |
border-color: #4CAF50; | |
} | |
.csv-results { | |
background: #424242; | |
border-radius: 8px; | |
padding: 12px; | |
max-height: 200px; | |
overflow-y: auto; | |
} | |
.csv-results h4 { | |
color: white; | |
margin: 0 0 8px 0; | |
font-size: 0.9rem; | |
} | |
.timestamp-list { | |
display: flex; | |
flex-direction: column; | |
gap: 4px; | |
} | |
.timestamp-item { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
padding: 4px 8px; | |
background: #363636; | |
border-radius: 4px; | |
cursor: pointer; | |
transition: background-color 0.2s ease; | |
} | |
.timestamp-item:hover { | |
background: #4a4a4a; | |
} | |
.timestamp-value { | |
color: #ccc; | |
font-size: 0.8rem; | |
} | |
.goto-timestamp-btn { | |
background: #4CAF50; | |
border: none; | |
border-radius: 3px; | |
color: white; | |
padding: 2px 6px; | |
font-size: 0.7rem; | |
cursor: pointer; | |
transition: background-color 0.2s ease; | |
} | |
.goto-timestamp-btn:hover { | |
background: #45a049; | |
} | |
/* Animations de transition */ | |
.slide-right-enter-active, .slide-right-leave-active, | |
.slide-left-enter-active, .slide-left-leave-active { | |
transition: transform 0.3s ease; | |
} | |
/* Animation vers la droite */ | |
.slide-right-enter-from { | |
transform: translateX(100%); | |
} | |
.slide-right-leave-to { | |
transform: translateX(-100%); | |
} | |
/* Animation vers la gauche */ | |
.slide-left-enter-from { | |
transform: translateX(-100%); | |
} | |
.slide-left-leave-to { | |
transform: translateX(100%); | |
} | |
</style> |