diff --git a/frontend/app.js b/frontend/app.js index a8698c9..e23552e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -346,14 +346,30 @@ class EncryptedVideoScroller { this.preloadNextVideo(); } - preloadNextVideo() { + async preloadNextVideo() { const nextIndex = this.currentIndex + 1; - if (nextIndex < this.videos.length && nextIndex > this.currentIndex + 1) { - // Preload video metadata + if (nextIndex < this.videos.length) { const nextVideo = this.videos[nextIndex]; - if (nextVideo) { - const img = new Image(); - img.src = `${this.apiUrl}${nextVideo.url}`; + // Only preload if not already cached + if (nextVideo && !this.decryptedBlobs[nextVideo.id]) { + try { + // Fetch encrypted video in background + const response = await fetch( + `${this.auth.apiUrl}/api/decrypt-video/${nextVideo.id}`, + { + headers: { 'Authorization': this.auth.getAuthHeader() } + } + ); + if (response.ok) { + // Cache the decrypted blob for smooth playback + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + this.decryptedBlobs[nextVideo.id] = blobUrl; + console.debug(`Preloaded video ${nextVideo.id}`); + } + } catch (error) { + console.debug('Preload of next video failed (non-critical):', error); + } } } } @@ -482,6 +498,9 @@ class TickyApp { const closeUploadBtn = document.getElementById('closeUploadBtn'); const uploadForm = document.getElementById('uploadForm'); const uploadError = document.getElementById('uploadError'); + const fileInput = document.getElementById('fileInput'); + const dropZone = document.getElementById('dropZone'); + const dropOverlay = document.getElementById('dropOverlay'); logoutBtn.addEventListener('click', () => { this.auth.logout(); @@ -502,43 +521,179 @@ class TickyApp { } }); - uploadForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const fileInput = document.getElementById('fileInput'); - const file = fileInput.files[0]; + // Drop zone click to select files + dropZone.addEventListener('click', () => { + fileInput.click(); + }); - uploadError.textContent = ''; - uploadError.style.display = 'none'; - - try { - const formData = new FormData(); - formData.append('file', file); - - const response = await fetch(`${this.auth.apiUrl}/api/upload`, { - method: 'POST', - headers: { - 'Authorization': this.auth.getAuthHeader() - }, - body: formData - }); - - if (!response.ok) { - throw new Error('Upload failed'); - } - - uploadForm.reset(); - uploadModal.classList.remove('show'); - - // Reload videos - await this.scroller.loadVideos(); - this.scroller.renderVideos(); - - alert('Video uploaded and encrypted successfully!'); - } catch (error) { - uploadError.textContent = 'Upload failed: ' + error.message; - uploadError.style.display = 'block'; + // File input change + fileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + this.handleFilesSelected(e.target.files); } }); + + // Drag and drop on drop zone + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.add('drag-over'); + }); + + dropZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove('drag-over'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove('drag-over'); + if (e.dataTransfer.files.length > 0) { + this.handleFilesSelected(e.dataTransfer.files); + } + }); + + // Drag and drop on entire window + document.addEventListener('dragover', (e) => { + if (uploadModal.classList.contains('show')) { + e.preventDefault(); + dropOverlay.classList.add('active'); + } + }); + + document.addEventListener('dragleave', (e) => { + if (e.clientX === 0 && e.clientY === 0) { + dropOverlay.classList.remove('active'); + } + }); + + document.addEventListener('drop', (e) => { + e.preventDefault(); + dropOverlay.classList.remove('active'); + }); + + uploadForm.addEventListener('submit', async (e) => { + e.preventDefault(); + if (fileInput.files.length === 0) { + uploadError.textContent = 'Please select at least one video'; + uploadError.style.display = 'block'; + return; + } + await this.uploadMultipleFiles(fileInput.files, uploadForm, uploadError); + }); + } + + async handleFilesSelected(files) { + const uploadForm = document.getElementById('uploadForm'); + const uploadError = document.getElementById('uploadError'); + uploadError.textContent = ''; + uploadError.style.display = 'none'; + await this.uploadMultipleFiles(files, uploadForm, uploadError); + } + + async uploadMultipleFiles(files, uploadForm, uploadError) { + const progressContainer = document.getElementById('uploadProgress'); + const progressList = document.getElementById('progressList'); + + if (files.length === 0) { + uploadError.textContent = 'Please select at least one video'; + uploadError.style.display = 'block'; + return; + } + + progressList.innerHTML = ''; + progressContainer.style.display = 'block'; + + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const progressItem = document.createElement('div'); + progressItem.className = 'progress-item'; + progressItem.innerHTML = ` +
${this.escapeHtml(file.name)}
+
+
+
+ `; + progressList.appendChild(progressItem); + + try { + await this.uploadSingleFile(file, progressItem); + progressItem.classList.add('success'); + successCount++; + } catch (error) { + console.error('Upload error:', error); + progressItem.classList.add('error'); + errorCount++; + } + } + + setTimeout(() => { + progressContainer.style.display = 'none'; + uploadForm.reset(); + + if (errorCount === 0) { + const msg = `Successfully uploaded ${successCount} video${successCount !== 1 ? 's' : ''}!`; + alert(msg); + document.getElementById('uploadModal').classList.remove('show'); + + // Reload videos + this.scroller.loadVideos().then(() => { + this.scroller.renderVideos(); + }); + } else { + uploadError.textContent = `Upload complete: ${successCount} succeeded, ${errorCount} failed`; + uploadError.style.display = 'block'; + } + }, 1000); + } + + async uploadSingleFile(file, progressItem) { + const progressFill = progressItem.querySelector('.progress-item-fill'); + const formData = new FormData(); + formData.append('file', file); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + progressFill.style.width = percentComplete + '%'; + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + progressFill.style.width = '100%'; + resolve(); + } else { + reject(new Error('Upload failed: ' + xhr.statusText)); + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Network error')); + }); + + xhr.addEventListener('abort', () => { + reject(new Error('Upload cancelled')); + }); + + xhr.open('POST', `${this.auth.apiUrl}/api/upload`); + xhr.setRequestHeader('Authorization', this.auth.getAuthHeader()); + xhr.send(formData); + }); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } } diff --git a/frontend/index.html b/frontend/index.html index 2369f38..56db87c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -48,19 +48,27 @@ diff --git a/frontend/styles.css b/frontend/styles.css index ca0921c..b0114e3 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -585,6 +585,110 @@ video::-webkit-media-controls { transition: width 0.2s; } +.file-drop-zone { + border: 2px dashed rgba(0, 245, 255, 0.5); + border-radius: 12px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + background: rgba(0, 245, 255, 0.05); +} + +.file-drop-zone:hover { + border-color: rgba(0, 245, 255, 0.8); + background: rgba(0, 245, 255, 0.1); +} + +.file-drop-zone.drag-over { + border-color: #00f5ff; + background: rgba(0, 245, 255, 0.2); + transform: scale(1.02); +} + +.file-drop-zone p { + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + margin: 0; +} + +.drop-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; + pointer-events: none; +} + +.drop-overlay.active { + display: flex; + pointer-events: auto; +} + +.drop-indicator { + color: white; + font-size: 32px; + font-weight: bold; + text-shadow: 0 0 20px rgba(0, 245, 255, 0.5); + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +#progressList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.progress-item { + padding: 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + border-left: 3px solid #00f5ff; +} + +.progress-item-name { + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.progress-item-bar { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + overflow: hidden; +} + +.progress-item-fill { + height: 100%; + background: linear-gradient(90deg, #ff006e, #00f5ff); + width: 0%; + transition: width 0.2s ease; +} + +.progress-item.success { + border-left-color: #00ff00; +} + +.progress-item.error { + border-left-color: #ff0000; +} + #loginError, #uploadError { color: #ff6b6b;