765 lines
25 KiB
JavaScript
765 lines
25 KiB
JavaScript
// Global error handler
|
|
window.addEventListener('error', (event) => {
|
|
console.error('[Ticky Error]', event.message, 'at', event.filename + ':' + event.lineno);
|
|
});
|
|
|
|
// Authentication Manager
|
|
class AuthManager {
|
|
constructor() {
|
|
this.token = localStorage.getItem('ticky_token');
|
|
this.pin = sessionStorage.getItem('ticky_pin');
|
|
const protocol = window.location.protocol;
|
|
const hostname = window.location.hostname;
|
|
this.apiUrl = `${protocol}//${hostname}:3001`;
|
|
}
|
|
|
|
async login(pin) {
|
|
try {
|
|
const response = await fetch(`${this.apiUrl}/api/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pin })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Invalid PIN');
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.token = data.token;
|
|
this.pin = pin;
|
|
localStorage.setItem('ticky_token', this.token);
|
|
sessionStorage.setItem('ticky_pin', this.pin);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Login failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
logout() {
|
|
this.token = null;
|
|
this.pin = null;
|
|
localStorage.removeItem('ticky_token');
|
|
sessionStorage.removeItem('ticky_pin');
|
|
}
|
|
|
|
isAuthenticated() {
|
|
return !!this.token && !!this.pin;
|
|
}
|
|
|
|
getAuthHeader() {
|
|
return `Bearer ${this.token}`;
|
|
}
|
|
}
|
|
|
|
// Encrypted Video Scroller
|
|
class EncryptedVideoScroller {
|
|
constructor(auth) {
|
|
this.auth = auth;
|
|
this.currentIndex = 0;
|
|
this.videos = [];
|
|
this.sequentialVideos = [];
|
|
this.randomMode = false;
|
|
this.isLoading = false;
|
|
this.container = document.getElementById('videoContainer');
|
|
this.loadingEl = document.getElementById('loading');
|
|
this.randomBtn = document.getElementById('randomBtn');
|
|
this.touchStartY = 0;
|
|
this.touchEndY = 0;
|
|
this.isDragging = false;
|
|
this.decryptedBlobs = {};
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
this.setupEventListeners();
|
|
this.setupIntersectionObserver();
|
|
await this.loadVideos();
|
|
this.renderVideos();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Touch events
|
|
document.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
|
|
document.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
|
document.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false });
|
|
|
|
// Keyboard
|
|
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
|
|
|
// Mouse wheel - now allows smooth scroll instead of snapping
|
|
document.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
|
|
|
// Scroll event to update currentIndex and manage auto-play
|
|
this.container.addEventListener('scroll', () => this.handleScroll());
|
|
|
|
// Toggle header on tap (but not on buttons)
|
|
this.container.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.download-btn') && !e.target.closest('.video-title')) {
|
|
this.toggleHeader();
|
|
}
|
|
});
|
|
|
|
// Random mode toggle button
|
|
if (this.randomBtn) {
|
|
this.randomBtn.addEventListener('click', () => this.toggleRandomMode());
|
|
}
|
|
}
|
|
|
|
setupIntersectionObserver() {
|
|
// Observer for loading/decryption - starts early
|
|
const loadOptions = {
|
|
root: this.container,
|
|
threshold: 0.05, // Trigger when 5% visible
|
|
rootMargin: '50%' // Start loading 50% before video enters viewport
|
|
};
|
|
|
|
this.loadObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
// Video is approaching or becoming visible - start decryption
|
|
const video = entry.target.querySelector('video');
|
|
const dataId = entry.target.dataset.index;
|
|
|
|
if (video && this.videos[dataId] && !this.decryptedBlobs[this.videos[dataId].id]) {
|
|
// Start decryption early so it's ready when scrolled into view
|
|
this.decryptAndPlayVideo(this.videos[dataId], video);
|
|
}
|
|
}
|
|
});
|
|
}, loadOptions);
|
|
|
|
// Observer for playback - ensures video is playing when visible
|
|
const playOptions = {
|
|
root: this.container,
|
|
threshold: 0.5 // Ensure playback when 50%+ visible
|
|
};
|
|
|
|
this.playObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
const video = entry.target.querySelector('video');
|
|
if (!video) return;
|
|
|
|
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
|
|
// Video is 50%+ visible - ensure it's playing
|
|
video.play().catch(e => console.log('Play error:', e));
|
|
} else {
|
|
// Not visible enough - pause it
|
|
video.pause();
|
|
}
|
|
});
|
|
}, playOptions);
|
|
}
|
|
|
|
toggleHeader() {
|
|
const header = document.querySelector('.app-header');
|
|
header.classList.toggle('hidden');
|
|
}
|
|
|
|
handleTouchStart(e) {
|
|
this.touchStartY = e.changedTouches[0].clientY;
|
|
this.isDragging = true;
|
|
}
|
|
|
|
handleTouchEnd(e) {
|
|
this.touchEndY = e.changedTouches[0].clientY;
|
|
this.isDragging = false;
|
|
this.handleSwipe();
|
|
}
|
|
|
|
handleSwipe() {
|
|
const diff = this.touchStartY - this.touchEndY;
|
|
const minSwipeDistance = 50;
|
|
|
|
if (Math.abs(diff) < minSwipeDistance) return;
|
|
|
|
if (diff > 0) {
|
|
// Swiped up - next video
|
|
this.nextVideo();
|
|
} else {
|
|
// Swiped down - previous video
|
|
this.prevVideo();
|
|
}
|
|
}
|
|
|
|
handleWheel(e) {
|
|
// Allow smooth scrolling - don't prevent default
|
|
// Just let native scroll happen
|
|
// Keyboard and swipe will still snap
|
|
}
|
|
|
|
handleScroll() {
|
|
// Update currentIndex based on which video is most visible
|
|
const containerTop = this.container.getBoundingClientRect().top;
|
|
const containerHeight = this.container.clientHeight;
|
|
const viewportCenter = containerHeight / 2;
|
|
|
|
let closestIndex = 0;
|
|
let closestDistance = Math.abs(containerHeight);
|
|
|
|
const items = this.container.querySelectorAll('.video-item');
|
|
items.forEach((item, index) => {
|
|
const rect = item.getBoundingClientRect();
|
|
const itemCenter = rect.top + rect.height / 2 - containerTop;
|
|
const distance = Math.abs(itemCenter - viewportCenter);
|
|
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
closestIndex = index;
|
|
}
|
|
});
|
|
|
|
this.currentIndex = closestIndex;
|
|
}
|
|
|
|
handleKeydown(e) {
|
|
if (e.key === 'ArrowDown' || e.key === ' ') {
|
|
e.preventDefault();
|
|
this.nextVideo();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
this.prevVideo();
|
|
}
|
|
}
|
|
|
|
async loadVideos() {
|
|
try {
|
|
this.showLoading();
|
|
const response = await fetch(`${this.auth.apiUrl}/api/encrypted-videos`, {
|
|
headers: {
|
|
'Authorization': this.auth.getAuthHeader()
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load videos');
|
|
}
|
|
|
|
const newVideos = await response.json();
|
|
this.sequentialVideos = newVideos;
|
|
this.videos = [...newVideos];
|
|
this.randomMode = false;
|
|
this.updateRandomButton();
|
|
} catch (error) {
|
|
console.error('Error loading videos:', error);
|
|
this.showLoadingError();
|
|
} finally {
|
|
this.hideLoading();
|
|
}
|
|
}
|
|
|
|
renderVideos() {
|
|
// Render all videos in DOM for smooth scrolling
|
|
// Clear existing videos
|
|
const existingItems = this.container.querySelectorAll('.video-item');
|
|
existingItems.forEach(item => item.remove());
|
|
|
|
// Render all videos
|
|
for (let i = 0; i < this.videos.length; i++) {
|
|
if (this.videos[i]) {
|
|
this.renderVideo(this.videos[i], i);
|
|
}
|
|
}
|
|
|
|
// Scroll to current video
|
|
this.scrollToVideo(this.currentIndex);
|
|
}
|
|
|
|
scrollToVideo(index) {
|
|
const item = this.container.querySelector(`[data-index="${index}"]`);
|
|
if (item) {
|
|
item.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}
|
|
|
|
renderVideo(video, index) {
|
|
const item = document.createElement('div');
|
|
item.className = 'video-item';
|
|
item.dataset.index = index;
|
|
item.style.minHeight = '100vh';
|
|
|
|
const videoEl = document.createElement('video');
|
|
videoEl.preload = 'auto';
|
|
videoEl.controls = false;
|
|
videoEl.loop = true;
|
|
videoEl.autoplay = false; // Intersection Observer will handle autoplay
|
|
videoEl.muted = true;
|
|
videoEl.playsInline = true;
|
|
videoEl.setAttribute('webkit-playsinline', 'webkit-playsinline');
|
|
videoEl.setAttribute('x5-playsinline', 'x5-playsinline');
|
|
videoEl.style.width = '100%';
|
|
videoEl.style.height = '100%';
|
|
videoEl.style.objectFit = 'contain';
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'video-info';
|
|
info.innerHTML = `
|
|
<div>
|
|
<div class="video-title">${this.escapeHtml(video.filename)}</div>
|
|
<div class="video-description">Scroll for more</div>
|
|
</div>
|
|
`;
|
|
|
|
const downloadBtn = document.createElement('button');
|
|
downloadBtn.className = 'download-btn';
|
|
downloadBtn.title = 'Download video';
|
|
downloadBtn.innerHTML = '⬇';
|
|
downloadBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
this.downloadVideo(video);
|
|
};
|
|
info.appendChild(downloadBtn);
|
|
|
|
item.appendChild(videoEl);
|
|
item.appendChild(info);
|
|
|
|
// Set up intersection observers for this video
|
|
if (this.loadObserver) {
|
|
this.loadObserver.observe(item);
|
|
}
|
|
if (this.playObserver) {
|
|
this.playObserver.observe(item);
|
|
}
|
|
|
|
this.container.appendChild(item);
|
|
}
|
|
|
|
async decryptAndPlayVideo(video, videoEl) {
|
|
try {
|
|
if (this.decryptedBlobs[video.id]) {
|
|
videoEl.src = this.decryptedBlobs[video.id];
|
|
// Don't auto-play - let observers handle it
|
|
return;
|
|
}
|
|
|
|
// Only show loading for visible videos
|
|
const isVisible = videoEl.parentElement?.getBoundingClientRect().top < this.container.clientHeight;
|
|
if (isVisible) {
|
|
this.showLoading();
|
|
}
|
|
|
|
const response = await fetch(
|
|
`${this.auth.apiUrl}/api/decrypt-video/${video.id}`,
|
|
{
|
|
headers: {
|
|
'Authorization': this.auth.getAuthHeader()
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to decrypt video');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
this.decryptedBlobs[video.id] = blobUrl;
|
|
|
|
videoEl.src = blobUrl;
|
|
// Don't auto-play - let observers handle it
|
|
this.hideLoading();
|
|
} catch (error) {
|
|
console.error('Decryption error:', error);
|
|
if (videoEl.parentElement?.getBoundingClientRect().top < this.container.clientHeight) {
|
|
this.showLoadingError();
|
|
}
|
|
}
|
|
}
|
|
|
|
async downloadVideo(video) {
|
|
try {
|
|
// Check if already decrypted
|
|
let blobUrl = this.decryptedBlobs[video.id];
|
|
|
|
if (!blobUrl) {
|
|
// Decrypt the video for download
|
|
const response = await fetch(
|
|
`${this.auth.apiUrl}/api/decrypt-video/${video.id}`,
|
|
{
|
|
headers: {
|
|
'Authorization': this.auth.getAuthHeader()
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to download video');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
blobUrl = URL.createObjectURL(blob);
|
|
}
|
|
|
|
// Create a temporary download link
|
|
const link = document.createElement('a');
|
|
link.href = blobUrl;
|
|
|
|
// Use original filename or generate one
|
|
const filename = video.filename || `video_${video.id}.mp4`;
|
|
link.download = filename;
|
|
|
|
// Trigger download
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
// Clean up the object URL if it wasn't already cached
|
|
if (!this.decryptedBlobs[video.id]) {
|
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
|
}
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
alert('Failed to download video: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async nextVideo() {
|
|
if (this.currentIndex < this.videos.length - 1) {
|
|
this.currentIndex++;
|
|
this.scrollToVideo(this.currentIndex);
|
|
}
|
|
}
|
|
|
|
prevVideo() {
|
|
if (this.currentIndex > 0) {
|
|
this.currentIndex--;
|
|
this.scrollToVideo(this.currentIndex);
|
|
}
|
|
}
|
|
|
|
toggleRandomMode() {
|
|
this.randomMode = !this.randomMode;
|
|
if (this.randomMode) {
|
|
// Shuffle videos array using Fisher-Yates shuffle
|
|
this.videos = [...this.videos].sort(() => Math.random() - 0.5);
|
|
} else {
|
|
// Restore original order
|
|
this.videos = [...this.sequentialVideos];
|
|
}
|
|
this.currentIndex = 0;
|
|
this.renderVideos();
|
|
this.updateRandomButton();
|
|
}
|
|
|
|
updateRandomButton() {
|
|
if (this.randomBtn) {
|
|
this.randomBtn.textContent = this.randomMode ? 'Random ▼' : 'Sequential ▼';
|
|
}
|
|
}
|
|
|
|
showLoading() {
|
|
this.isLoading = true;
|
|
this.loadingEl.classList.add('show');
|
|
this.loadingEl.innerHTML = '<div class="spinner"></div>';
|
|
}
|
|
|
|
hideLoading() {
|
|
this.isLoading = false;
|
|
this.loadingEl.classList.remove('show');
|
|
}
|
|
|
|
showLoadingError() {
|
|
this.loadingEl.innerHTML = 'Failed to load';
|
|
this.loadingEl.classList.add('show');
|
|
setTimeout(() => this.loadingEl.classList.remove('show'), 3000);
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
}
|
|
|
|
// App Controller
|
|
class TickyApp {
|
|
constructor() {
|
|
this.auth = new AuthManager();
|
|
this.scroller = null;
|
|
this.init();
|
|
}
|
|
|
|
async checkFirstLogin() {
|
|
try {
|
|
// Try to access a protected endpoint without auth to see if marker file exists
|
|
const response = await fetch(`${this.auth.apiUrl}/api/health`);
|
|
return !response.ok;
|
|
} catch {
|
|
return true; // Assume first login if we can't check
|
|
}
|
|
}
|
|
|
|
init() {
|
|
if (this.auth.isAuthenticated()) {
|
|
this.showApp();
|
|
} else {
|
|
this.showLogin();
|
|
}
|
|
}
|
|
|
|
showLogin() {
|
|
const loginScreen = document.getElementById('loginScreen');
|
|
const mainApp = document.getElementById('mainApp');
|
|
loginScreen.classList.add('active');
|
|
mainApp.classList.remove('active');
|
|
|
|
const loginForm = document.getElementById('loginForm');
|
|
const pinInput = document.getElementById('pinInput');
|
|
const loginError = document.getElementById('loginError');
|
|
const setupMode = document.getElementById('setupMode');
|
|
|
|
// Check if this is first login by attempting to fetch marker file status
|
|
this.checkFirstLogin().then(isFirstLogin => {
|
|
if (isFirstLogin) {
|
|
setupMode.style.display = 'block';
|
|
pinInput.placeholder = 'Create 6-digit PIN';
|
|
}
|
|
});
|
|
|
|
loginForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const pin = pinInput.value;
|
|
|
|
if (!pin.match(/^\d{6}$/)) {
|
|
loginError.textContent = 'PIN must be 6 digits';
|
|
loginError.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.auth.login(pin);
|
|
this.showApp();
|
|
} catch (error) {
|
|
loginError.textContent = 'Invalid PIN';
|
|
loginError.style.display = 'block';
|
|
pinInput.value = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
showApp() {
|
|
const loginScreen = document.getElementById('loginScreen');
|
|
const mainApp = document.getElementById('mainApp');
|
|
loginScreen.classList.remove('active');
|
|
mainApp.classList.add('active');
|
|
|
|
if (!this.scroller) {
|
|
this.scroller = new EncryptedVideoScroller(this.auth);
|
|
}
|
|
|
|
this.setupAppControls();
|
|
}
|
|
|
|
setupAppControls() {
|
|
const logoutBtn = document.getElementById('logoutBtn');
|
|
const uploadBtn = document.getElementById('uploadBtn');
|
|
const uploadModal = document.getElementById('uploadModal');
|
|
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();
|
|
window.location.reload();
|
|
});
|
|
|
|
uploadBtn.addEventListener('click', () => {
|
|
uploadModal.classList.add('show');
|
|
});
|
|
|
|
closeUploadBtn.addEventListener('click', () => {
|
|
uploadModal.classList.remove('show');
|
|
});
|
|
|
|
uploadModal.addEventListener('click', (e) => {
|
|
if (e.target === uploadModal) {
|
|
uploadModal.classList.remove('show');
|
|
}
|
|
});
|
|
|
|
// Drop zone click to select files
|
|
dropZone.addEventListener('click', () => {
|
|
fileInput.click();
|
|
});
|
|
|
|
// 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 = `
|
|
<div class="progress-item-name">${this.escapeHtml(file.name)}</div>
|
|
<div class="progress-item-bar">
|
|
<div class="progress-item-fill" style="width: 0%"></div>
|
|
</div>
|
|
`;
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new TickyApp();
|
|
});
|