Adding encryption
This commit is contained in:
369
frontend/app.js
369
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 = `<div class="video-title">${this.escapeHtml(video.title)}</div>`;
|
||||
info.innerHTML = `
|
||||
<div>
|
||||
<div class="video-title">${this.escapeHtml(video.filename)}</div>
|
||||
<div class="video-description">Swipe up 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);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, maximum-scale=1.0">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Ticky">
|
||||
@@ -10,12 +10,59 @@
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="video-container" id="videoContainer">
|
||||
<!-- Videos will be inserted here -->
|
||||
<!-- Login Screen -->
|
||||
<div id="loginScreen" class="login-screen active">
|
||||
<div class="login-container">
|
||||
<h1>Frankie's Den</h1>
|
||||
<div id="setupMode" style="display: none;">
|
||||
<p class="setup-text">First time setup - Create a 6-digit PIN</p>
|
||||
</div>
|
||||
<form id="loginForm">
|
||||
<input type="password" id="pinInput" placeholder="Enter PIN" maxlength="6" inputmode="numeric" required>
|
||||
<button type="submit">Unlock</button>
|
||||
</form>
|
||||
<div id="loginError" class="error-message"></div>
|
||||
</div>
|
||||
<div class="loading" id="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Main App -->
|
||||
<div id="mainApp" class="main-app">
|
||||
<!-- Header -->
|
||||
<div class="app-header">
|
||||
<h2>Private Videos</h2>
|
||||
<div class="header-buttons">
|
||||
<button id="uploadBtn" class="btn-small">+ Upload</button>
|
||||
<button id="logoutBtn" class="btn-small">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Viewer -->
|
||||
<div class="video-viewer-container">
|
||||
<div class="video-container" id="videoContainer">
|
||||
<!-- Videos will be inserted here -->
|
||||
</div>
|
||||
<div class="loading" id="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="uploadModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Upload Encrypted Video</h3>
|
||||
<button class="close-btn" id="closeUploadBtn">×</button>
|
||||
</div>
|
||||
<form id="uploadForm">
|
||||
<input type="file" id="fileInput" accept="video/*" required>
|
||||
<button type="submit">Encrypt & Upload</button>
|
||||
</form>
|
||||
<div id="uploadError" class="error-message"></div>
|
||||
<div id="uploadProgress" class="progress-bar" style="display: none;">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user