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> |