PointTrackApp / src /components /SegmentationSidebar.vue
2nzi's picture
update personnal fps
e173aa4 verified
<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>