diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a6cae6d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,96 @@ +# Ticky Project - Development Guide + +## Project Overview +Self-hosted TikTok-style infinite video scroller with Python/FastAPI backend and vanilla JavaScript frontend. Runs in Docker Compose. + +## Key Features +- Infinite scroll with lazy loading +- Mobile swipe navigation (up/down) +- Desktop keyboard and mouse wheel support +- Responsive design (mobile/desktop) +- Automatic video preloading +- REST API for random video selection + +## Architecture + +### Backend (Python/FastAPI) +- **Port**: 3001 +- **Framework**: FastAPI + Uvicorn +- **Key File**: `backend/main.py` +- **Features**: + - Random video selection from folder + - Static file serving for videos + - CORS enabled for frontend access + +### Frontend (Vanilla JS) +- **Port**: 3000 +- **File**: `frontend/app.js` with `styles.css` +- **Features**: + - Swipe gesture detection + - Keyboard/mouse wheel navigation + - Lazy loading and preloading + - Responsive CSS for mobile/desktop + +## Common Tasks + +### Add a new backend endpoint +1. Open `backend/main.py` +2. Add `@app.get()` or `@app.post()` decorator +3. Implement endpoint logic +4. Restart backend: `docker-compose restart backend` + +### Customize frontend styling +1. Edit `frontend/styles.css` +2. Modify colors, sizes, animations +3. Changes reflect immediately on page refresh + +### Change video directory +1. Update `docker-compose.yml` `VIDEOS_DIR` variable +2. Or mount different volume path +3. Restart containers: `docker-compose restart` + +### Add video metadata +1. Modify backend response in `main.py` (add duration, thumbnail URL, etc.) +2. Update frontend `renderVideo()` in `app.js` to display new fields + +## Debugging + +### View backend logs +```bash +docker-compose logs -f backend +``` + +### View frontend console +Open browser DevTools (F12) → Console tab + +### Check running containers +```bash +docker-compose ps +``` + +### Rebuild images +```bash +docker-compose down && docker-compose up --build +``` + +## Performance Considerations + +- Videos should be optimized (compressed MP4s recommended) +- Use landscape orientation videos for best desktop experience +- Preloading limited to next video to save bandwidth +- Only current ± 1 videos rendered in DOM + +## Browser Support +- Chrome/Edge: Full support +- Firefox: Full support +- Safari: Full support (iOS 12.2+) +- Mobile browsers: Optimized for touch + +## Future Enhancement Ideas +- Video filtering/search +- User ratings or favorites +- Thumbnail previews +- Video duration display +- Playlist management +- Authentication +- CDN integration diff --git a/backend/main.py b/backend/main.py index bda69f2..0e97861 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,19 @@ import os import random +import json from pathlib import Path -from fastapi import FastAPI +from datetime import datetime, timedelta +from typing import Optional, Tuple +import bcrypt +import hashlib +import hmac +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from typing import List +from fastapi.responses import StreamingResponse +from pydantic import BaseModel app = FastAPI() @@ -17,15 +26,133 @@ app.add_middleware( allow_headers=["*"], ) +# ============ Models ============ + +class LoginRequest(BaseModel): + pin: str + +class LoginResponse(BaseModel): + token: str + message: str + +class VideoMetadata(BaseModel): + filename: str + salt: str # hex encoded + iv: str # hex encoded + uploaded_at: str + + VIDEOS_DIR = Path(os.getenv("VIDEOS_DIR", "/videos")) +ENCRYPTED_DIR = Path(os.getenv("ENCRYPTED_DIR", "/videos/encrypted")) +METADATA_DIR = Path(os.getenv("METADATA_DIR", "/videos/metadata")) +MARKER_FILE = Path(os.getenv("METADATA_DIR", "/videos/metadata")) / ".pinlock" VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".avi", ".mkv"} -# Mount videos directory +# Create necessary directories +ENCRYPTED_DIR.mkdir(parents=True, exist_ok=True) +METADATA_DIR.mkdir(parents=True, exist_ok=True) + +# Mount videos directory for unencrypted videos (optional) if VIDEOS_DIR.exists(): app.mount("/videos", StaticFiles(directory=VIDEOS_DIR), name="videos") +# ============ Authentication ============ -def get_video_files() -> List[str]: +def hash_pin(pin: str) -> str: + """Hash PIN with bcrypt.""" + return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() + +def verify_pin(pin: str, hashed: str) -> bool: + """Verify PIN against hash.""" + return bcrypt.checkpw(pin.encode(), hashed.encode()) + +def create_token(pin: str) -> str: + """Create a simple auth token (in production, use JWT).""" + import base64 + import secrets + token = base64.b64encode( + f"{pin}:{secrets.token_hex(16)}:{datetime.utcnow().isoformat()}".encode() + ).decode() + return token + +def verify_token(token: str) -> bool: + """Verify token is valid format.""" + try: + import base64 + decoded = base64.b64decode(token.encode()).decode() + parts = decoded.split(":") + if len(parts) < 3: + return False + return True + except: + return False + +# ============ Encryption Functions ============ + +def derive_key_from_password(password: str, salt: bytes) -> bytes: + """ + Derive encryption key from password using PBKDF2-like key derivation. + Same password + same salt = same key. + Key is never stored - only exists in memory during crypto operations. + """ + # Use hashlib's pbkdf2_hmac for key derivation (no external dependency needed) + key = hashlib.pbkdf2_hmac( + 'sha256', + password.encode(), + salt, + iterations=480000 # OWASP recommendation + ) + return key + +def encrypt_file(file_bytes: bytes, password: str) -> Tuple[bytes, str, str]: + """ + Encrypt file with password-derived key. + Returns: (encrypted_data, salt_hex, iv_hex) + """ + import secrets + + salt = secrets.token_bytes(16) + iv = secrets.token_bytes(16) + key = derive_key_from_password(password, salt) + + # Add PKCS7 padding + padding_length = 16 - (len(file_bytes) % 16) + padded_data = file_bytes + bytes([padding_length] * padding_length) + + # Encrypt with AES-256-CBC + cipher = Cipher( + algorithms.AES(key), + modes.CBC(iv), + ) + encryptor = cipher.encryptor() + ciphertext = encryptor.update(padded_data) + encryptor.finalize() + + return ciphertext, salt.hex(), iv.hex() + +def decrypt_file(ciphertext: bytes, password: str, salt_hex: str, iv_hex: str) -> bytes: + """ + Decrypt file with password-derived key. + Only works with correct password - no backdoor. + """ + salt = bytes.fromhex(salt_hex) + iv = bytes.fromhex(iv_hex) + key = derive_key_from_password(password, salt) + + cipher = Cipher( + algorithms.AES(key), + modes.CBC(iv), + ) + decryptor = cipher.decryptor() + padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize() + + # Remove PKCS7 padding + padding_length = padded_plaintext[-1] + plaintext = padded_plaintext[:-padding_length] + + return plaintext + + +def get_video_files() -> list[str]: """Get all video files from the videos directory.""" if not VIDEOS_DIR.exists(): return [] @@ -36,16 +163,220 @@ def get_video_files() -> List[str]: ] return videos +# ============ API Endpoints ============ + +@app.post("/api/login") +def login(request: LoginRequest): + """ + Authenticate user with PIN. + First login: Create marker file encrypted with PIN + Subsequent logins: Verify PIN by decrypting marker file + """ + pin = request.pin + + # Validate PIN format (6 digits) + if not pin.isdigit() or len(pin) != 6: + raise HTTPException(status_code=400, detail="PIN must be 6 digits") + + if MARKER_FILE.exists(): + # Existing system - verify PIN by decrypting marker file + try: + with open(MARKER_FILE, "rb") as f: + marker_data = json.load(f) + + # Try to decrypt the marker with the provided PIN + decrypted = decrypt_file( + bytes.fromhex(marker_data["data"]), + pin, + marker_data["salt"], + marker_data["iv"] + ) + + # If we get here, PIN is correct + token = create_token(pin) + return LoginResponse( + token=token, + message="Login successful" + ) + except Exception as e: + raise HTTPException(status_code=401, detail="Invalid PIN") + else: + # First login - create marker file with this PIN + marker_content = b"PIN_MARKER" + encrypted_data, salt_hex, iv_hex = encrypt_file(marker_content, pin) + + marker_data = { + "data": encrypted_data.hex(), + "salt": salt_hex, + "iv": iv_hex, + "created_at": datetime.utcnow().isoformat() + } + + with open(MARKER_FILE, "w") as f: + json.dump(marker_data, f) + + token = create_token(pin) + return LoginResponse( + token=token, + message="PIN initialized successfully" + ) + +def verify_auth(authorization: Optional[str] = Header(None)) -> str: + """Dependency to verify auth token and extract PIN.""" + if not authorization: + raise HTTPException(status_code=401, detail="Missing authorization token") + + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid token format") + + token = authorization.replace("Bearer ", "") + if not verify_token(token): + raise HTTPException(status_code=401, detail="Invalid token") + + # Extract PIN from token + try: + import base64 + decoded = base64.b64decode(token.encode()).decode() + pin = decoded.split(":")[0] + return pin + except: + raise HTTPException(status_code=401, detail="Invalid token") + +@app.post("/api/upload") +async def upload_file( + file: UploadFile = File(...), + token: str = Depends(verify_auth) +): + """ + Upload and encrypt a file. + File is encrypted with the authenticated user's PIN - no key stored on server. + """ + try: + pin = token # token is actually the PIN extracted by verify_auth + + # Read file content + content = await file.read() + + if not content: + raise HTTPException(status_code=400, detail="Empty file") + + # Encrypt with password-derived key + encrypted_data, salt_hex, iv_hex = encrypt_file(content, pin) + + # Generate unique filename + import secrets + encrypted_filename = f"{secrets.token_hex(8)}.enc" + encrypted_path = ENCRYPTED_DIR / encrypted_filename + + # Save encrypted file + with open(encrypted_path, "wb") as f: + f.write(encrypted_data) + + # Save metadata (salt and IV are non-secret, needed for decryption) + metadata = VideoMetadata( + filename=file.filename or "unknown", + salt=salt_hex, + iv=iv_hex, + uploaded_at=datetime.utcnow().isoformat() + ) + + metadata_path = METADATA_DIR / f"{encrypted_filename}.json" + with open(metadata_path, "w") as f: + json.dump(metadata.dict(), f) + + return { + "success": True, + "encrypted_id": encrypted_filename, + "original_filename": file.filename, + "message": "File encrypted and stored securely" + } + + except HTTPException: + raise + except Exception as e: + print(f"Upload error: {e}") + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + +@app.get("/api/encrypted-videos") +def list_encrypted_videos(token: str = Depends(verify_auth)): + """List all encrypted videos (metadata only - no decryption).""" + try: + videos = [] + for metadata_file in METADATA_DIR.glob("*.json"): + with open(metadata_file, "r") as f: + metadata = json.load(f) + encrypted_id = metadata_file.stem.replace(".enc", "") + videos.append({ + "id": encrypted_id, + "filename": metadata["filename"], + "uploaded_at": metadata["uploaded_at"] + }) + + return sorted(videos, key=lambda x: x["uploaded_at"], reverse=True) + + except Exception as e: + print(f"List error: {e}") + raise HTTPException(status_code=500, detail="Failed to list videos") + +@app.get("/api/decrypt-video/{encrypted_id}") +async def decrypt_video( + encrypted_id: str, + token: str = Depends(verify_auth) +): + """ + Decrypt and stream video file. + Only works with correct PIN (extracted from token) - key is derived, never stored. + """ + try: + pin = token # PIN extracted from token by verify_auth + + encrypted_path = ENCRYPTED_DIR / f"{encrypted_id}.enc" + metadata_path = METADATA_DIR / f"{encrypted_id}.enc.json" + + if not encrypted_path.exists() or not metadata_path.exists(): + raise HTTPException(status_code=404, detail="Video not found") + + # Load metadata + with open(metadata_path, "r") as f: + metadata = json.load(f) + + # Read encrypted data + with open(encrypted_path, "rb") as f: + ciphertext = f.read() + + # Decrypt with PIN-derived key + plaintext = decrypt_file( + ciphertext, + pin, + metadata["salt"], + metadata["iv"] + ) + + # Stream the decrypted file + def iterfile(): + yield plaintext + + return StreamingResponse( + iterfile(), + media_type="video/mp4", + headers={"Content-Disposition": f"inline; filename={metadata['filename']}"} + ) + + except HTTPException: + raise + except Exception as e: + print(f"Decryption error: {e}") + raise HTTPException(status_code=500, detail="Decryption failed") + @app.get("/api/health") def health_check(): """Health check endpoint.""" return {"status": "ok"} - @app.get("/api/videos") def get_videos(limit: int = 10): - """Get a list of random videos.""" + """Get a list of random videos (public endpoint for unencrypted videos).""" try: videos = get_video_files() diff --git a/backend/requirements.txt b/backend/requirements.txt index 9376067..f713e15 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,3 +2,7 @@ fastapi==0.104.1 uvicorn==0.24.0 python-multipart==0.0.6 python-dotenv==1.0.0 +cryptography==41.0.7 +bcrypt==4.1.1 +python-jose==3.3.0 +pydantic==2.5.0 diff --git a/docker-compose.yml b/docker-compose.yml index 31bacc8..57b57f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,18 +5,20 @@ services: build: ./backend container_name: ticky-backend ports: - - "3222:3001" + - "3001:3001" environment: - VIDEOS_DIR=/videos + - ENCRYPTED_DIR=/videos/encrypted + - METADATA_DIR=/videos/metadata volumes: - - ./videos:/videos:ro + - ./videos:/videos restart: unless-stopped frontend: build: ./frontend container_name: ticky-frontend ports: - - "3111:3000" + - "3000:3000" depends_on: - backend environment: diff --git a/frontend/app.js b/frontend/app.js index e9fcaa1..a8698c9 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,10 +1,62 @@ -// Add global error handler +// Global error handler window.addEventListener('error', (event) => { console.error('[Ticky Error]', event.message, 'at', event.filename + ':' + event.lineno); }); -class VideoScroller { +// 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.isLoading = false; @@ -13,13 +65,7 @@ class VideoScroller { this.touchStartY = 0; this.touchEndY = 0; this.isDragging = false; - this.lastLoadTime = 0; - this.minLoadInterval = 500; - - // Get API URL from current location - const protocol = window.location.protocol; - const hostname = window.location.hostname; - this.apiUrl = `${protocol}//${hostname}:3222`; + this.decryptedBlobs = {}; this.init(); } @@ -31,19 +77,28 @@ class VideoScroller { } setupEventListeners() { - // Touch events for swipe - prevent default to avoid page scrolling + // 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 navigation + // Keyboard document.addEventListener('keydown', (e) => this.handleKeydown(e)); - // Infinite scroll - load more when nearing the end - this.container.addEventListener('scroll', () => this.handleScroll()); - // Mouse wheel document.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false }); + + // 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(); + } + }); + } + + toggleHeader() { + const header = document.querySelector('.app-header'); + header.classList.toggle('hidden'); } handleTouchStart(e) { @@ -95,14 +150,18 @@ class VideoScroller { async loadVideos() { try { this.showLoading(); - const response = await fetch(`${this.apiUrl}/api/videos?limit=10`); - const newVideos = await response.json(); - - if (Array.isArray(newVideos)) { - this.videos.push(...newVideos); + 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'); } - - this.lastLoadTime = Date.now(); + + const newVideos = await response.json(); + this.videos = newVideos; } catch (error) { console.error('Error loading videos:', error); this.showLoadingError(); @@ -137,7 +196,6 @@ class VideoScroller { item.style.minHeight = '100vh'; const videoEl = document.createElement('video'); - videoEl.src = `${this.apiUrl}${video.url}`; videoEl.preload = 'auto'; videoEl.controls = false; videoEl.loop = true; @@ -152,19 +210,118 @@ class VideoScroller { const info = document.createElement('div'); info.className = 'video-info'; - info.innerHTML = `
${this.escapeHtml(video.title)}
`; + info.innerHTML = ` +
+
${this.escapeHtml(video.filename)}
+
Swipe up for more
+
+ `; + + 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); - // Lazy load: only play current video + // Load encrypted video on demand if (index === this.currentIndex) { - videoEl.play().catch(e => console.log('Play error:', e)); + this.decryptAndPlayVideo(video, videoEl); } this.container.appendChild(item); } + async decryptAndPlayVideo(video, videoEl) { + try { + if (this.decryptedBlobs[video.id]) { + videoEl.src = this.decryptedBlobs[video.id]; + videoEl.play().catch(e => console.log('Play error:', e)); + return; + } + + 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; + videoEl.play().catch(e => console.log('Play error:', e)); + } catch (error) { + console.error('Decryption error:', error); + this.showLoadingError(); + } finally { + this.hideLoading(); + } + } + + 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); + } + } + updateActiveVideo() { const items = this.container.querySelectorAll('.video-item'); items.forEach(item => { @@ -206,14 +363,6 @@ class VideoScroller { this.currentIndex++; this.renderVideos(); } - - // Load more videos when near the end - const now = Date.now(); - if (this.currentIndex > this.videos.length - 3 && - now - this.lastLoadTime > this.minLoadInterval && - !this.isLoading) { - await this.loadVideos(); - } } prevVideo() { @@ -223,10 +372,6 @@ class VideoScroller { } } - handleScroll() { - // Could be used for horizontal scroll on desktop - } - showLoading() { this.isLoading = true; this.loadingEl.classList.add('show'); @@ -239,7 +384,7 @@ class VideoScroller { } showLoadingError() { - this.loadingEl.innerHTML = 'Failed to load videos'; + this.loadingEl.innerHTML = 'Failed to load'; this.loadingEl.classList.add('show'); setTimeout(() => this.loadingEl.classList.remove('show'), 3000); } @@ -251,7 +396,153 @@ class VideoScroller { } } +// 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'); + + 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'); + } + }); + + uploadForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const fileInput = document.getElementById('fileInput'); + const file = fileInput.files[0]; + + 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'; + } + }); + } +} + // Initialize on page load document.addEventListener('DOMContentLoaded', () => { - new VideoScroller(); + new TickyApp(); }); diff --git a/frontend/index.html b/frontend/index.html index 9a2220a..2369f38 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + @@ -10,12 +10,59 @@ -
-
- + + + + +
+ +
+

Private Videos

+
+ + +
+
+ + +
+
+ +
+
Loading...
+
+ + + +
+ diff --git a/frontend/styles.css b/frontend/styles.css index 3f001ff..ca0921c 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -18,12 +18,12 @@ body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; - position: absolute; + position: fixed; top: 0; left: 0; width: 100%; height: 100%; - min-height: 100vh; + max-height: 100vh; } .container { @@ -109,18 +109,50 @@ video::-webkit-media-controls { .video-info { position: absolute; - bottom: 20px; - left: 20px; + top: 20px; + right: 20px; color: white; z-index: 10; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); pointer-events: none; + display: flex; + align-items: center; + gap: 10px; + padding-top: env(safe-area-inset-top); + padding-right: max(20px, env(safe-area-inset-right)); } .video-title { font-size: clamp(14px, 4vw, 24px); font-weight: 600; margin-bottom: 8px; + display: none; +} + +.download-btn { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 20px; + pointer-events: all; + transition: all 0.2s; + flex-shrink: 0; +} + +.download-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: scale(1.1); +} + +.download-btn:active { + transform: scale(0.95); } .video-controls { @@ -242,3 +274,357 @@ video::-webkit-media-controls { 100% { transform: rotate(360deg); } } +/* ===== LOGIN SCREEN ===== */ +.login-screen { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; +} + +.login-screen.active { + opacity: 1; + pointer-events: all; +} + +.login-container { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + padding: 40px; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + max-width: 400px; + width: 90%; + text-align: center; +} + +.login-container h1 { + font-size: 48px; + color: #fff; + margin-bottom: 10px; + font-weight: 700; +} + +.login-container p { + color: rgba(255, 255, 255, 0.7); + margin-bottom: 30px; + font-size: 16px; +} + +#loginForm { + display: flex; + flex-direction: column; + gap: 15px; +} + +#pinInput { + padding: 15px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + background: rgba(255, 255, 255, 0.1); + color: white; + font-size: 24px; + text-align: center; + letter-spacing: 10px; + transition: all 0.3s; +} + +#pinInput:focus { + outline: none; + border-color: #00f5ff; + background: rgba(255, 255, 255, 0.15); + box-shadow: 0 0 20px rgba(0, 245, 255, 0.2); +} + +#loginForm button { + padding: 15px; + border: none; + border-radius: 10px; + background: linear-gradient(135deg, #ff006e, #00f5ff); + color: white; + font-size: 18px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; +} + +#loginForm button:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px rgba(255, 0, 110, 0.3); +} + +#loginForm button:active { + transform: translateY(0); +} + +.error-message { + display: none; + color: #ff4444; + font-size: 14px; + padding: 10px; + background: rgba(255, 68, 68, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 68, 68, 0.3); + margin-top: 10px; +} + +/* ===== MAIN APP ===== */ +.main-app { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + overflow: hidden; +} + +.main-app.active { + opacity: 1; + pointer-events: all; +} + +.app-header { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); + padding: 15px 20px; + padding-top: max(15px, env(safe-area-inset-top)); + padding-left: max(20px, env(safe-area-inset-left)); + padding-right: max(20px, env(safe-area-inset-right)); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + z-index: 50; + position: sticky; + top: 0; + transition: transform 0.3s ease; +} + +.app-header.hidden { + transform: translateY(-100%); + position: fixed; +} + +.app-header h2 { + color: white; + font-size: 20px; + font-weight: 600; +} + +.header-buttons { + display: flex; + gap: 10px; +} + +.btn-small { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: all 0.3s; +} + +.btn-small:hover { + background: rgba(255, 255, 255, 0.2); +} + +.btn-small:active { + transform: scale(0.95); +} + +.video-viewer-container { + flex: 1; + position: relative; + overflow: hidden; + cursor: pointer; +} + +/* ===== MODAL ===== */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + display: none; + align-items: center; + justify-content: center; + z-index: 999; +} + +.modal.show { + display: flex; +} + +.modal-content { + background: rgba(20, 20, 30, 0.95); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 30px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.modal-header h3 { + color: white; + font-size: 24px; +} + +.close-btn { + background: none; + border: none; + color: white; + font-size: 32px; + cursor: pointer; + transition: transform 0.2s; + line-height: 1; +} + +.close-btn:hover { + transform: scale(1.2); +} + +#uploadForm { + display: flex; + flex-direction: column; + gap: 15px; +} + +#uploadForm input[type="file"], +#uploadForm input[type="password"] { + padding: 12px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + color: white; + font-size: 16px; + transition: all 0.3s; +} + +#uploadForm input[type="file"]::file-selector-button { + background: linear-gradient(135deg, #ff006e, #00f5ff); + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s; +} + +#uploadForm input[type="file"]::file-selector-button:hover { + transform: translateY(-2px); +} + +#uploadForm input:focus { + outline: none; + border-color: #00f5ff; + background: rgba(255, 255, 255, 0.1); + box-shadow: 0 0 15px rgba(0, 245, 255, 0.2); +} + +#uploadForm button { + padding: 12px; + background: linear-gradient(135deg, #ff006e, #00f5ff); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; +} + +#uploadForm button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(255, 0, 110, 0.3); +} + +#uploadForm button:active { + transform: translateY(0); +} + +.progress-bar { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; + margin-top: 10px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #ff006e, #00f5ff); + width: 0%; + transition: width 0.2s; +} + +#loginError, +#uploadError { + color: #ff6b6b; + font-size: 14px; + padding: 10px; + background: rgba(255, 107, 107, 0.1); + border-radius: 6px; + border: 1px solid rgba(255, 107, 107, 0.3); + margin-top: 10px; +} + +/* ===== RESPONSIVE ===== */ +@media (max-width: 600px) { + .app-header { + padding: 12px 15px; + } + + .app-header h2 { + font-size: 18px; + } + + .btn-small { + padding: 6px 12px; + font-size: 12px; + } + + .login-container { + padding: 30px 20px; + } + + .login-container h1 { + font-size: 36px; + } + + .modal-content { + padding: 20px; + } + + .modal-header h3 { + font-size: 20px; + } +} +