diff --git a/frontends/web/static/js/stegasoo.js b/frontends/web/static/js/stegasoo.js new file mode 100644 index 0000000..c9773d4 --- /dev/null +++ b/frontends/web/static/js/stegasoo.js @@ -0,0 +1,741 @@ +/** + * Stegasoo Frontend JavaScript + * Shared functionality across encode, decode, and generate pages + */ + +const Stegasoo = { + + // ======================================================================== + // PASSWORD/PIN VISIBILITY TOGGLES + // ======================================================================== + + initPasswordToggles() { + document.querySelectorAll('[data-toggle-password]').forEach(btn => { + btn.addEventListener('click', function() { + const targetId = this.dataset.togglePassword; + const input = document.getElementById(targetId); + const icon = this.querySelector('i'); + + if (!input) return; + + if (input.type === 'password') { + input.type = 'text'; + icon?.classList.replace('bi-eye', 'bi-eye-slash'); + } else { + input.type = 'password'; + icon?.classList.replace('bi-eye-slash', 'bi-eye'); + } + }); + }); + }, + + // ======================================================================== + // RSA INPUT METHOD TOGGLE (File vs QR) + // ======================================================================== + + initRsaMethodToggle() { + const fileRadio = document.getElementById('rsaMethodFile'); + const qrRadio = document.getElementById('rsaMethodQr'); + const fileSection = document.getElementById('rsaFileSection'); + const qrSection = document.getElementById('rsaQrSection'); + + if (!fileRadio || !qrRadio || !fileSection || !qrSection) return; + + const update = () => { + const isFile = fileRadio.checked; + fileSection.classList.toggle('d-none', !isFile); + qrSection.classList.toggle('d-none', isFile); + }; + + fileRadio.addEventListener('change', update); + qrRadio.addEventListener('change', update); + }, + + // ======================================================================== + // DROP ZONES (Drag & Drop + Preview) + // ======================================================================== + + initDropZones(options = {}) { + document.querySelectorAll('.drop-zone').forEach(zone => { + const input = zone.querySelector('input[type="file"]'); + const label = zone.querySelector('.drop-zone-label'); + const preview = zone.querySelector('.drop-zone-preview'); + + if (!input) return; + + // Check if this is a special zone type + const isPayloadZone = zone.id === 'payloadDropZone'; + const isCarrierZone = zone.id === 'carrierDropZone'; + const isQrZone = zone.id === 'qrDropZone'; + + // Drag events + ['dragenter', 'dragover'].forEach(evt => { + zone.addEventListener(evt, e => { + e.preventDefault(); + zone.classList.add('drag-over'); + }); + }); + + ['dragleave', 'drop'].forEach(evt => { + zone.addEventListener(evt, e => { + e.preventDefault(); + zone.classList.remove('drag-over'); + }); + }); + + // Drop handler + zone.addEventListener('drop', e => { + if (e.dataTransfer.files.length) { + input.files = e.dataTransfer.files; + input.dispatchEvent(new Event('change')); + } + }); + + // Change handler for preview (skip payload and QR zones - they have special handling) + if (!isPayloadZone && !isQrZone) { + input.addEventListener('change', function() { + if (this.files && this.files[0]) { + Stegasoo.showImagePreview(this.files[0], preview, label, zone); + } + }); + } + }); + }, + + showImagePreview(file, previewEl, labelEl, zone = null) { + if (!file.type.startsWith('image/')) return; + + const isScanContainer = zone && zone.classList.contains('scan-container'); + const isPixelContainer = zone && zone.classList.contains('pixel-container'); + + const reader = new FileReader(); + reader.onload = e => { + if (previewEl) { + previewEl.src = e.target.result; + previewEl.classList.remove('d-none'); + } + // For scan/pixel containers, hide the label entirely (filename will appear in data panel) + if (labelEl) { + if (isScanContainer || isPixelContainer) { + labelEl.classList.add('d-none'); + } else { + labelEl.innerHTML = '' + file.name; + } + } + + // Trigger appropriate animation + if (isScanContainer) { + Stegasoo.triggerScanAnimation(zone, file); + } else if (isPixelContainer) { + Stegasoo.triggerPixelReveal(zone, file); + } + }; + reader.readAsDataURL(file); + }, + + // ======================================================================== + // REFERENCE PHOTO SCAN ANIMATION + // ======================================================================== + + triggerScanAnimation(container, file, duration = 700) { + // Reset any previous state + container.classList.remove('scan-complete'); + container.classList.add('scanning'); + + const preview = container.querySelector('.drop-zone-preview'); + + // Create hash blocks for the "hashing" visual effect + const createHashBlocks = () => { + // Remove old hash blocks + const oldBlocks = container.querySelector('.hash-blocks'); + if (oldBlocks) oldBlocks.remove(); + + const hashContainer = document.createElement('div'); + hashContainer.className = 'hash-blocks'; + + // Size and position to match preview image exactly + const imgWidth = preview.offsetWidth; + const imgHeight = preview.offsetHeight; + const imgTop = preview.offsetTop; + const imgLeft = preview.offsetLeft; + + hashContainer.style.width = imgWidth + 'px'; + hashContainer.style.height = imgHeight + 'px'; + hashContainer.style.top = imgTop + 'px'; + hashContainer.style.left = imgLeft + 'px'; + + // Create grid of hash blocks (10x8 for better coverage) + const cols = 10; + const rows = 8; + + hashContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; + hashContainer.style.gridTemplateRows = `repeat(${rows}, 1fr)`; + + // Create blocks with staggered delays for wave disappearance + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const block = document.createElement('div'); + block.className = 'hash-block'; + // Diagonal wave pattern for disappearance + const delay = (row + col) * 25 + Math.random() * 30; + block.style.animationDelay = delay + 'ms'; + hashContainer.appendChild(block); + } + } + + container.appendChild(hashContainer); + }; + + // Wait for image to be ready + if (preview.complete && preview.naturalWidth) { + createHashBlocks(); + } else { + preview.onload = createHashBlocks; + } + + // After animation duration, switch to complete state + setTimeout(() => { + container.classList.remove('scanning'); + container.classList.add('scan-complete'); + + // Remove hash blocks container + const hashBlocks = container.querySelector('.hash-blocks'); + if (hashBlocks) hashBlocks.remove(); + + // Populate data panel if file provided + if (file) { + const nameEl = container.querySelector('#refFileName') || container.querySelector('.scan-data-filename span'); + const sizeEl = container.querySelector('#refFileSize') || container.querySelector('.scan-data-value'); + const hashEl = container.querySelector('#refHashPreview') || container.querySelector('.scan-hash-preview'); + + if (nameEl) { + nameEl.textContent = file.name; + } + + if (sizeEl) { + const sizeKB = (file.size / 1024).toFixed(1); + const sizeMB = (file.size / (1024 * 1024)).toFixed(2); + sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`; + } + + if (hashEl) { + // Generate a deterministic fake hash preview from filename + size + const fakeHash = Stegasoo.generateFakeHash(file.name + file.size); + hashEl.textContent = `SHA256: ${fakeHash.substring(0, 8)}····${fakeHash.substring(56)}`; + } + } + }, duration); + }, + + generateFakeHash(input) { + // Simple deterministic hash-like string for display purposes + let hash = ''; + const chars = '0123456789abcdef'; + let seed = 0; + for (let i = 0; i < input.length; i++) { + seed = ((seed << 5) - seed) + input.charCodeAt(i); + seed = seed & seed; + } + for (let i = 0; i < 64; i++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + hash += chars[seed % 16]; + } + return hash; + }, + + // ======================================================================== + // CARRIER/STEGO PIXEL REVEAL ANIMATION + // ======================================================================== + + triggerPixelReveal(container, file, duration = 700) { + // Reset any previous state + container.classList.remove('load-complete'); + container.classList.add('loading'); + + const preview = container.querySelector('.drop-zone-preview'); + + // Create embed traces container sized to image + const createTraces = () => { + // Remove old elements + let tracesContainer = container.querySelector('.embed-traces'); + if (tracesContainer) tracesContainer.remove(); + let oldGrid = container.querySelector('.embed-grid'); + if (oldGrid) oldGrid.remove(); + + // Add grid overlay (covers whole panel like ref does) + const grid = document.createElement('div'); + grid.className = 'embed-grid'; + container.appendChild(grid); + + // Create traces container + tracesContainer = document.createElement('div'); + tracesContainer.className = 'embed-traces'; + container.appendChild(tracesContainer); + + // Size and position traces to match preview image exactly + const imgWidth = preview.offsetWidth; + const imgHeight = preview.offsetHeight; + const imgTop = preview.offsetTop; + const imgLeft = preview.offsetLeft; + + tracesContainer.style.width = imgWidth + 'px'; + tracesContainer.style.height = imgHeight + 'px'; + tracesContainer.style.top = imgTop + 'px'; + tracesContainer.style.left = imgLeft + 'px'; + + // Generate Tron-style circuit traces covering the image + Stegasoo.generateEmbedTraces(tracesContainer, imgWidth, imgHeight); + }; + + // Wait for image to be ready + if (preview.complete && preview.naturalWidth) { + createTraces(); + } else { + preview.onload = createTraces; + } + + setTimeout(() => { + container.classList.remove('loading'); + container.classList.add('load-complete'); + + // Remove traces and grid + const traces = container.querySelector('.embed-traces'); + if (traces) traces.remove(); + const grid = container.querySelector('.embed-grid'); + if (grid) grid.remove(); + + // Populate data panel + Stegasoo.populatePixelDataPanel(container, file, preview); + }, duration); + }, + + generateEmbedTraces(container, width, height) { + // Color classes for variety + const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue']; + + // Generate 6-8 snake paths spread across the whole image + const numPaths = 6 + Math.floor(Math.random() * 3); + + for (let p = 0; p < numPaths; p++) { + // Each path gets a random color + const pathColor = colors[Math.floor(Math.random() * colors.length)]; + + // Distribute starting points across the image + let x = (width * 0.1) + (Math.random() * width * 0.8); + let y = (height * 0.1) + (Math.random() * height * 0.8); + let delay = p * 40; + + // Each path has 3-5 segments for more coverage + const numSegments = 3 + Math.floor(Math.random() * 3); + let horizontal = Math.random() > 0.5; + + for (let s = 0; s < numSegments; s++) { + const trace = document.createElement('div'); + trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor; + + const length = 30 + Math.random() * 60; + trace.style.left = x + 'px'; + trace.style.top = y + 'px'; + trace.style.animationDelay = delay + 'ms'; + + if (horizontal) { + trace.style.width = length + 'px'; + } else { + trace.style.height = length + 'px'; + } + + container.appendChild(trace); + + // Move position for next segment + if (horizontal) { + x += length; + } else { + y += length; + } + + // Wrap around if out of bounds to keep traces in view + if (x > width - 20) x = 10 + Math.random() * 40; + if (y > height - 20) y = 10 + Math.random() * 40; + if (x < 10) x = width - 60 + Math.random() * 40; + if (y < 10) y = height - 60 + Math.random() * 40; + + // Alternate direction (90 degree turn) + horizontal = !horizontal; + delay += 30; + } + } + }, + + populatePixelDataPanel(container, file, preview) { + const nameEl = container.querySelector('.pixel-data-filename span'); + const sizeEl = container.querySelector('.pixel-data-value'); + const dimsEl = container.querySelector('.pixel-dimensions'); + + if (nameEl) { + nameEl.textContent = file.name; + } + + if (sizeEl) { + const sizeKB = (file.size / 1024).toFixed(1); + const sizeMB = (file.size / (1024 * 1024)).toFixed(2); + sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`; + } + + if (dimsEl && preview) { + dimsEl.textContent = `${preview.naturalWidth} × ${preview.naturalHeight} px`; + } + }, + + initReferenceScanAnimation() { + // Find all scan containers and wire up their inputs + document.querySelectorAll('.scan-container').forEach(container => { + const input = container.querySelector('input[type="file"]'); + const preview = container.querySelector('.drop-zone-preview'); + const label = container.querySelector('.drop-zone-label'); + + if (!input) return; + + input.addEventListener('change', function() { + if (this.files && this.files[0]) { + Stegasoo.showImagePreview(this.files[0], preview, label, container); + } + }); + }); + }, + + // ======================================================================== + // CLIPBOARD PASTE + // ======================================================================== + + initClipboardPaste(imageInputSelectors) { + document.addEventListener('paste', function(e) { + const items = e.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const blob = items[i].getAsFile(); + + // Find first empty input from the list + let targetInput = null; + for (const selector of imageInputSelectors) { + const input = document.querySelector(selector); + if (input && (!input.files || !input.files.length)) { + targetInput = input; + break; + } + } + + // Fallback to first input if all have files + if (!targetInput) { + targetInput = document.querySelector(imageInputSelectors[0]); + } + + if (targetInput) { + const container = new DataTransfer(); + container.items.add(blob); + targetInput.files = container.files; + targetInput.dispatchEvent(new Event('change')); + } + break; + } + } + }); + }, + + // ======================================================================== + // QR CODE CROP ANIMATION WITH SECTION SCANNING + // ======================================================================== + + initQrCropAnimation(inputId = 'rsaKeyQrInput') { + const input = document.getElementById(inputId); + const container = document.getElementById('qrCropContainer'); + const original = document.getElementById('qrOriginal'); + const cropped = document.getElementById('qrCropped'); + const dropZone = document.getElementById('qrDropZone'); + + if (!input || !container || !original || !cropped) return; + + input.addEventListener('change', function() { + if (!this.files || !this.files[0]) return; + + const file = this.files[0]; + if (!file.type.startsWith('image/')) return; + + const label = dropZone?.querySelector('.drop-zone-label'); + + // Reset animation state + container.classList.remove('scan-complete', 'scanning'); + container.classList.add('d-none'); + + // Remove old overlay if exists + const oldOverlay = container.querySelector('.qr-section-overlay'); + if (oldOverlay) oldOverlay.remove(); + + // Show loading state immediately + container.classList.remove('d-none'); + container.classList.add('loading'); + label?.classList.add('d-none'); + + // Add loading indicator if not present + let loader = container.querySelector('.qr-loader'); + if (!loader) { + loader = document.createElement('div'); + loader.className = 'qr-loader'; + loader.innerHTML = ` + + Detecting QR code... + `; + container.appendChild(loader); + } + + // Fetch cropped version + const formData = new FormData(); + formData.append('image', file); + + fetch('/qr/crop', { + method: 'POST', + body: formData + }) + .then(response => { + if (!response.ok) throw new Error('No QR detected'); + return response.blob(); + }) + .then(blob => { + // Hide loader, show cropped image + container.classList.remove('loading'); + cropped.src = URL.createObjectURL(blob); + + return new Promise((resolve) => { + cropped.onload = () => { + // Start scanning animation + container.classList.add('scanning'); + + // Add scanner overlay - will be positioned via CSS to cover the image + let overlay = container.querySelector('.qr-scanner-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'qr-scanner-overlay'; + + ['tl', 'tr', 'bl', 'br'].forEach(pos => { + const bracket = document.createElement('div'); + bracket.className = `qr-finder-bracket ${pos}`; + overlay.appendChild(bracket); + }); + + // Add data panel inside overlay + const dataPanel = document.createElement('div'); + dataPanel.className = 'qr-data-panel'; + dataPanel.innerHTML = ` +
+ KEY LOADED + -- +
+ `; + overlay.appendChild(dataPanel); + + container.appendChild(overlay); + } + + // Let CSS handle overlay positioning (inset with padding) + resolve(); + }; + }); + }) + .then(() => { + // Now verify key extraction + const keyFormData = new FormData(); + keyFormData.append('qr_image', file); + + return fetch('/extract-key-from-qr', { + method: 'POST', + body: keyFormData + }); + }) + .then(response => response.json()) + .then(data => { + // Extraction complete - stop animation + container.classList.remove('scanning'); + container.classList.add('scan-complete'); + + // Update data panel (inside overlay) + const overlay = container.querySelector('.qr-scanner-overlay'); + const sizeEl = overlay?.querySelector('.qr-data-value'); + + if (data.success && sizeEl) { + const sizeKB = (file.size / 1024).toFixed(1); + sizeEl.textContent = sizeKB + ' KB'; + } + }) + .catch(err => { + console.log('QR crop/extract error:', err); + container.classList.remove('loading', 'scanning'); + container.classList.add('error'); + + // Update loader to show error + const loader = container.querySelector('.qr-loader'); + if (loader) { + loader.innerHTML = ` + + No QR code detected + `; + } + }); + }); + }, + + // ======================================================================== + // COLLAPSE CHEVRON ANIMATION + // ======================================================================== + + initCollapseChevrons() { + document.querySelectorAll('[data-chevron]').forEach(collapse => { + const chevronId = collapse.dataset.chevron; + const chevron = document.getElementById(chevronId); + + if (!chevron) return; + + collapse.addEventListener('show.bs.collapse', () => { + chevron.classList.add('bi-chevron-up'); + chevron.classList.remove('bi-chevron-down'); + }); + + collapse.addEventListener('hide.bs.collapse', () => { + chevron.classList.remove('bi-chevron-up'); + chevron.classList.add('bi-chevron-down'); + }); + }); + }, + + // ======================================================================== + // FORM LOADING STATE + // ======================================================================== + + initFormLoading(formId, buttonId, loadingText = 'Processing...') { + const form = document.getElementById(formId); + const btn = document.getElementById(buttonId); + + if (!form || !btn) return; + + form.addEventListener('submit', () => { + btn.disabled = true; + btn.innerHTML = `${loadingText}`; + }); + }, + + // ======================================================================== + // COPY TO CLIPBOARD + // ======================================================================== + + copyToClipboard(text, iconEl, textEl) { + navigator.clipboard.writeText(text).then(() => { + const origIcon = iconEl?.className; + const origText = textEl?.textContent; + + if (iconEl) iconEl.className = 'bi bi-check'; + if (textEl) textEl.textContent = 'Copied!'; + + setTimeout(() => { + if (iconEl) iconEl.className = origIcon; + if (textEl) textEl.textContent = origText; + }, 2000); + }); + }, + + // ======================================================================== + // MODE CARD HIGHLIGHTING + // ======================================================================== + + initModeCards(config) { + // config: { radioName: 'embed_mode', cards: { 'lsb': { id: 'lsbCard', borderClass: 'border-primary' }, ... } } + const radios = document.querySelectorAll(`input[name="${config.radioName}"]`); + + const update = () => { + radios.forEach(radio => { + const cardConfig = config.cards[radio.value]; + if (!cardConfig) return; + + const card = document.getElementById(cardConfig.id); + if (!card) return; + + card.classList.toggle(cardConfig.borderClass, radio.checked); + card.classList.toggle('border-2', radio.checked); + }); + }; + + radios.forEach(radio => radio.addEventListener('change', update)); + update(); // Initial state + }, + + // ======================================================================== + // PASSPHRASE FONT SIZE AUTO-ADJUST + // ======================================================================== + + initPassphraseFontResize(inputId = 'passphraseInput') { + const input = document.getElementById(inputId); + if (!input) return; + + const steps = [ + { maxChars: 30, size: 1.1 }, + { maxChars: 45, size: 1.0 }, + { maxChars: 60, size: 0.95 }, + { maxChars: Infinity, size: 0.9 } + ]; + + const adjust = () => { + const len = input.value.length; + for (const step of steps) { + if (len <= step.maxChars) { + input.style.fontSize = step.size + 'rem'; + break; + } + } + }; + + input.addEventListener('input', adjust); + adjust(); + }, + + // ======================================================================== + // INITIALIZATION HELPERS + // ======================================================================== + + initEncodePage() { + this.initPasswordToggles(); + this.initRsaMethodToggle(); + this.initDropZones(); + this.initClipboardPaste(['input[name="carrier"]', 'input[name="reference_photo"]']); + this.initQrCropAnimation('rsaQrInput'); + this.initCollapseChevrons(); + this.initFormLoading('encodeForm', 'encodeBtn', 'Encoding...'); + this.initPassphraseFontResize(); + }, + + initDecodePage() { + this.initPasswordToggles(); + this.initRsaMethodToggle(); + this.initDropZones(); + this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']); + this.initQrCropAnimation('rsaKeyQrInput'); + this.initCollapseChevrons(); + this.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...'); + this.initPassphraseFontResize(); + }, + + initGeneratePage() { + this.initPasswordToggles(); + // Generate page has mostly unique functionality + } +}; + +// Auto-init based on page +document.addEventListener('DOMContentLoaded', () => { + // Detect page and initialize + if (document.getElementById('encodeForm')) { + Stegasoo.initEncodePage(); + } else if (document.getElementById('decodeForm')) { + Stegasoo.initDecodePage(); + } else if (document.querySelector('[data-page="generate"]')) { + Stegasoo.initGeneratePage(); + } +}); diff --git a/frontends/web/static/style.css b/frontends/web/static/style.css index 2c1e569..da149e9 100644 --- a/frontends/web/static/style.css +++ b/frontends/web/static/style.css @@ -26,6 +26,46 @@ font-weight: 700 !important; } +/* ---------------------------------------------------------------------------- + Mode Selection Buttons (Compact) + ---------------------------------------------------------------------------- */ +.mode-btn { + background: var(--overlay-light); + border: 2px solid var(--border-light); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.mode-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); +} + +.mode-btn.active { + border-color: var(--gradient-start); + background: rgba(102, 126, 234, 0.1); +} + +.mode-btn .form-check-input { + margin-top: 0; + flex-shrink: 0; +} + +.mode-info-icon { + cursor: help; + opacity: 0.6; + font-size: 0.85rem; +} + +.mode-info-icon:hover { + opacity: 1; +} + /* ---------------------------------------------------------------------------- Base Styles ---------------------------------------------------------------------------- */ @@ -407,3 +447,835 @@ footer { transform: translateY(-5px); box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3); } + +/* ---------------------------------------------------------------------------- + Reference Photo Scan Animation + ---------------------------------------------------------------------------- */ +.scan-container { + position: relative; + overflow: hidden; +} + +.scan-container .drop-zone-preview { + position: relative; + z-index: 1; + margin-bottom: 45px; +} + +.scan-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 2; + opacity: 0; + transition: opacity 0.3s ease; +} + +.scan-container.scanning .scan-overlay { + opacity: 1; +} + +/* Scan line that moves down */ +.scan-line { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + transparent 0%, + rgba(0, 255, 170, 0.4) 20%, + rgba(0, 255, 170, 1) 50%, + rgba(0, 255, 170, 0.4) 80%, + transparent 100% + ); + box-shadow: 0 0 10px rgba(0, 255, 170, 0.6), 0 0 20px rgba(0, 255, 170, 0.3); + opacity: 0; +} + +.scan-container.scanning .scan-line { + opacity: 1; + animation: scanDown 0.7s ease-out forwards; +} + +@keyframes scanDown { + 0% { top: 0; opacity: 1; } + 100% { top: 100%; opacity: 0; } +} + +/* Grid overlay - subtle */ +.scan-grid { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(rgba(0, 255, 170, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 255, 170, 0.03) 1px, transparent 1px); + background-size: 8px 8px; + opacity: 0; +} + +.scan-container.scanning .scan-grid { + opacity: 0.5; +} + +/* Hash blocks container - masked to image, positioned over it */ +.hash-blocks { + position: absolute; + z-index: 3; + pointer-events: none; + overflow: hidden; + border-radius: 0.375rem; + display: grid; + gap: 2px; +} + +.hash-block { + background: rgba(0, 255, 170, 0.6); + border-radius: 1px; + box-shadow: 0 0 4px rgba(0, 255, 170, 0.5); + opacity: 0; +} + +/* Hash blocks fade in then out one by one */ +.scan-container.scanning .hash-block { + animation: hashBlockPulse 0.7s ease-in-out forwards; +} + +@keyframes hashBlockPulse { + 0% { + opacity: 0; + transform: scale(0.8); + } + 15% { + opacity: 0.7; + transform: scale(1); + background: rgba(0, 255, 170, 0.6); + } + 40% { + opacity: 0.8; + background: rgba(0, 255, 170, 0.7); + box-shadow: 0 0 6px rgba(0, 255, 170, 0.6); + } + 70% { + opacity: 0.6; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.5); + } +} + +/* Corner brackets - hidden during scan, shown after */ +.scan-corners { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.scan-corner { + position: absolute; + width: 16px; + height: 16px; + border-color: rgba(0, 255, 170, 0.8); + border-style: solid; + border-width: 0; +} + +.scan-corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; } +.scan-corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; } +.scan-corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; } +.scan-corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; } + +/* Scan complete state */ +.scan-container.scan-complete .scan-overlay { + opacity: 0; +} + +.scan-container.scan-complete .scan-corners { + opacity: 1; +} + +.scan-container.scan-complete .drop-zone-preview { + animation: scanPop 0.3s ease-out; +} + +@keyframes scanPop { + 0% { transform: scale(1); } + 50% { transform: scale(1.02); } + 100% { transform: scale(1); } +} + +/* Data panel that appears after scan */ +.scan-data-panel { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 3; + background: linear-gradient(to top, + rgba(10, 15, 30, 0.98) 0%, + rgba(12, 20, 40, 0.9) 50%, + rgba(15, 25, 50, 0.6) 75%, + transparent 100%); + padding: 35px 10px 8px 10px; + opacity: 0; + transform: translateY(4px); + transition: opacity 0.4s ease, transform 0.4s ease; + pointer-events: none; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.scan-container.scan-complete .scan-data-panel { + opacity: 1; + transform: translateY(0); +} + +.scan-data-filename { + font-family: 'Courier New', monospace; + font-size: 0.7rem; + color: #fff; + text-align: center; + margin-bottom: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.scan-data-filename i { + color: rgba(0, 255, 170, 1); + margin-right: 4px; +} + +.scan-data-row { + display: flex; + justify-content: space-between; + align-items: center; + font-family: 'Courier New', monospace; + font-size: 0.6rem; + color: rgba(0, 255, 170, 0.9); + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.scan-data-label { + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + font-size: 0.55rem; +} + +.scan-data-value { + color: rgba(0, 255, 170, 1); + font-weight: 600; +} + +.scan-hash-preview { + font-family: 'Courier New', monospace; + font-size: 0.55rem; + color: rgba(0, 255, 170, 0.6); + letter-spacing: 0.5px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scan-status-badge { + display: inline-flex; + align-items: center; + background: rgba(0, 255, 170, 0.15); + border: 1px solid rgba(0, 255, 170, 0.4); + border-radius: 3px; + padding: 2px 6px; + font-size: 0.5rem; + color: rgba(0, 255, 170, 1); + text-transform: uppercase; + letter-spacing: 0.3px; + line-height: 1; +} + +/* ---------------------------------------------------------------------------- + Carrier/Stego Embed Animation - Hidden data threads + ---------------------------------------------------------------------------- */ +.pixel-container { + position: relative; + overflow: hidden; +} + +.pixel-container .drop-zone-preview { + position: relative; + z-index: 1; + margin-bottom: 45px; +} + +/* Scan line effect - smooth single pass */ +.pixel-scan-line { + position: absolute; + left: 0; + right: 0; + height: 2px; + top: 0; + background: linear-gradient(90deg, + transparent 0%, + rgba(212, 225, 87, 0.4) 20%, + rgba(212, 225, 87, 1) 50%, + rgba(212, 225, 87, 0.4) 80%, + transparent 100% + ); + box-shadow: 0 0 10px rgba(212, 225, 87, 0.6), 0 0 20px rgba(212, 225, 87, 0.3); + opacity: 0; + z-index: 5; + pointer-events: none; +} + +.pixel-container.loading .pixel-scan-line { + opacity: 1; + animation: embedScanDown 0.7s ease-out forwards; +} + +@keyframes embedScanDown { + 0% { top: 0; opacity: 1; } + 100% { top: calc(100% - 45px); opacity: 0; } +} + +/* Grid overlay - matches reference scan-grid exactly */ +.embed-grid { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 3; + pointer-events: none; + background-image: + linear-gradient(rgba(0, 255, 170, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 255, 170, 0.03) 1px, transparent 1px); + background-size: 8px 8px; + opacity: 0; +} + +.pixel-container.loading .embed-grid { + opacity: 0.5; +} + +/* Tron-style circuit traces container - masked to image */ +.embed-traces { + position: absolute; + z-index: 4; + pointer-events: none; + overflow: hidden; + border-radius: 0.375rem; +} + +.embed-trace { + position: absolute; + border-radius: 1px; + opacity: 0; +} + +/* Color variants - 60% opacity */ +.embed-trace.color-yellow { + background: rgba(212, 225, 87, 0.6); + box-shadow: 0 0 6px rgba(212, 225, 87, 0.5), 0 0 12px rgba(212, 225, 87, 0.3); +} + +.embed-trace.color-cyan { + background: rgba(0, 255, 170, 0.6); + box-shadow: 0 0 6px rgba(0, 255, 170, 0.5), 0 0 12px rgba(0, 255, 170, 0.3); +} + +.embed-trace.color-purple { + background: rgba(167, 139, 250, 0.6); + box-shadow: 0 0 6px rgba(167, 139, 250, 0.5), 0 0 12px rgba(167, 139, 250, 0.3); +} + +.embed-trace.color-blue { + background: rgba(102, 126, 234, 0.6); + box-shadow: 0 0 6px rgba(102, 126, 234, 0.5), 0 0 12px rgba(102, 126, 234, 0.3); +} + +/* Vertical segments shrink from top */ +.embed-trace.v { + width: 2px; + transform-origin: top center; +} + +/* Horizontal segments shrink from left */ +.embed-trace.h { + height: 2px; + transform-origin: left center; +} + +/* Animation - appear, glow, shrink away completely */ +.pixel-container.loading .embed-trace.h { + animation: traceHShrink 0.65s ease-in-out forwards; +} + +.pixel-container.loading .embed-trace.v { + animation: traceVShrink 0.65s ease-in-out forwards; +} + +@keyframes traceHShrink { + 0% { + transform: scaleX(0); + opacity: 0; + } + 15% { + transform: scaleX(1); + opacity: 1; + } + 40% { + transform: scaleX(1); + opacity: 1; + } + 100% { + transform: scaleX(0); + opacity: 0; + } +} + +@keyframes traceVShrink { + 0% { + transform: scaleY(0); + opacity: 0; + } + 15% { + transform: scaleY(1); + opacity: 1; + } + 40% { + transform: scaleY(1); + opacity: 1; + } + 100% { + transform: scaleY(0); + opacity: 0; + } +} + +/* Ensure traces are gone after animation */ +.pixel-container.load-complete .embed-traces { + display: none; +} + +/* Corner brackets for pixel container - purple/blue theme */ +.pixel-corners { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.pixel-corner { + position: absolute; + width: 16px; + height: 16px; + border-color: rgba(212, 225, 87, 0.8); + border-style: solid; + border-width: 0; +} + +.pixel-corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; } +.pixel-corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; } +.pixel-corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; } +.pixel-corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; } + +.pixel-container.load-complete .pixel-corners { + opacity: 1; +} + +/* Data panel for pixel container - purple/blue theme */ +.pixel-data-panel { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 3; + background: linear-gradient(to top, + rgba(10, 15, 30, 0.98) 0%, + rgba(12, 20, 40, 0.9) 50%, + rgba(15, 25, 50, 0.6) 75%, + transparent 100%); + padding: 35px 10px 8px 10px; + opacity: 0; + transform: translateY(4px); + transition: opacity 0.4s ease, transform 0.4s ease; + pointer-events: none; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.pixel-container.load-complete .pixel-data-panel { + opacity: 1; + transform: translateY(0); +} + +.pixel-data-filename { + font-family: 'Courier New', monospace; + font-size: 0.7rem; + color: #fff; + text-align: center; + margin-bottom: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pixel-data-filename i { + color: #d4e157; + margin-right: 4px; +} + +.pixel-data-row { + display: flex; + justify-content: space-between; + align-items: center; + font-family: 'Courier New', monospace; + font-size: 0.6rem; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.pixel-data-value { + color: #d4e157; + font-weight: 600; +} + +.pixel-status-badge { + display: inline-flex; + align-items: center; + background: rgba(212, 225, 87, 0.15); + border: 1px solid rgba(212, 225, 87, 0.4); + border-radius: 3px; + padding: 2px 6px; + font-size: 0.55rem; + color: #d4e157; + text-transform: uppercase; + letter-spacing: 0.5px; + line-height: 1; +} + +.pixel-dimensions { + font-family: 'Courier New', monospace; + font-size: 0.55rem; + color: rgba(212, 225, 87, 0.7); + letter-spacing: 0.5px; + text-align: center; +} + +/* ---------------------------------------------------------------------------- + QR Code Section - Square Inner Panel Layout + ---------------------------------------------------------------------------- */ +#rsaQrSection { + display: flex; + justify-content: center; +} + +#rsaQrSection .drop-zone { + width: 200px; + height: 200px; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + transition: all 0.3s ease; +} + +/* Expand drop zone when showing scanned QR result */ +#rsaQrSection .drop-zone:has(.qr-scan-container:not(.d-none)) { + width: auto; + min-width: 200px; + max-width: 280px; + height: auto; + min-height: 200px; + aspect-ratio: auto; +} + +#rsaQrSection .drop-zone-label { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +/* ---------------------------------------------------------------------------- + QR Code Section Scan Animation - Simplified: Loading → Scan → Complete + ---------------------------------------------------------------------------- */ +.qr-scan-container { + position: relative; + overflow: visible; + border-radius: 8px; + background: rgba(0, 0, 0, 0.3); + min-height: 160px; + min-width: 160px; + padding: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.qr-scan-container img { + display: block; + margin: 0 auto; +} + +/* Hide original image - we don't use it anymore */ +.qr-scan-container .qr-original { + display: none; +} + +/* Cropped image - hidden until loaded, scales UP to fill container */ +.qr-scan-container .qr-cropped { + max-height: 180px; + max-width: 180px; + min-width: 140px; + min-height: 140px; + width: auto; + height: auto; + object-fit: contain; + display: none; + border-radius: 6px; +} + +/* =========================================== + PHASE 1: Loading - spinner while cropping + =========================================== */ +.qr-loader { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: rgba(0, 255, 170, 0.9); + padding: 30px 20px; + width: 100%; +} + +.qr-loader i { + font-size: 2.5rem; + animation: qrLoaderPulse 1.5s ease-in-out infinite; +} + +.qr-loader span { + font-family: 'Courier New', monospace; + font-size: 0.75rem; + letter-spacing: 0.5px; + opacity: 0.8; +} + +@keyframes qrLoaderPulse { + 0%, 100% { opacity: 0.5; transform: scale(0.95); } + 50% { opacity: 1; transform: scale(1.05); } +} + +.qr-scan-container.loading .qr-loader { + display: flex; +} + +/* Error state */ +.qr-scan-container.error .qr-loader { + display: flex; + color: #fbbf24; +} + +.qr-scan-container.error .qr-loader i { + animation: none; +} + +/* =========================================== + PHASE 2: Scanning - cropped QR with brackets + =========================================== */ +.qr-scan-container.scanning .qr-cropped, +.qr-scan-container.scan-complete .qr-cropped { + display: block; +} + +/* Scanner overlay - covers the container, padded to match image area */ +.qr-scanner-overlay { + position: absolute; + inset: 10px; + pointer-events: none; + z-index: 5; + border-radius: 6px; + overflow: visible; +} + +/* Four corner finder brackets */ +.qr-finder-bracket { + position: absolute; + width: 18px; + height: 18px; + border-color: rgba(0, 255, 170, 0.9); + border-style: solid; + border-width: 0; + opacity: 0; + transition: all 0.3s ease; +} + +.qr-finder-bracket.tl { top: 0; left: 0; border-top-width: 3px; border-left-width: 3px; } +.qr-finder-bracket.tr { top: 0; right: 0; border-top-width: 3px; border-right-width: 3px; } +.qr-finder-bracket.bl { bottom: 0; left: 0; border-bottom-width: 3px; border-left-width: 3px; } +.qr-finder-bracket.br { bottom: 0; right: 0; border-bottom-width: 3px; border-right-width: 3px; } + +/* Scanning state - brackets pulse */ +.qr-scan-container.scanning .qr-finder-bracket { + opacity: 1; + box-shadow: 0 0 8px rgba(0, 255, 170, 0.6); + animation: bracketPulse 1s ease-in-out infinite; +} + +.qr-scan-container.scanning .qr-finder-bracket.tl { animation-delay: 0s; } +.qr-scan-container.scanning .qr-finder-bracket.tr { animation-delay: 0.15s; } +.qr-scan-container.scanning .qr-finder-bracket.br { animation-delay: 0.3s; } +.qr-scan-container.scanning .qr-finder-bracket.bl { animation-delay: 0.45s; } + +@keyframes bracketPulse { + 0%, 100% { opacity: 0.6; border-color: rgba(0, 255, 170, 0.6); box-shadow: 0 0 4px rgba(0, 255, 170, 0.4); } + 50% { opacity: 1; border-color: rgba(0, 255, 170, 1); box-shadow: 0 0 12px rgba(0, 255, 170, 0.8); } +} + +/* Scan line during scanning phase */ +.qr-scan-container.scanning .qr-scanner-overlay::before { + content: ''; + position: absolute; + left: 2px; + right: 2px; + height: 2px; + top: 2px; + z-index: 6; + background: linear-gradient(90deg, + transparent 0%, + rgba(0, 255, 170, 0.5) 20%, + rgba(0, 255, 170, 1) 50%, + rgba(0, 255, 170, 0.5) 80%, + transparent 100% + ); + box-shadow: 0 0 8px rgba(0, 255, 170, 0.8); + animation: qrScanLine 1s ease-in-out infinite; +} + +@keyframes qrScanLine { + 0% { top: 2px; opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { top: calc(100% - 2px); opacity: 0; } +} + +/* Grid overlay during scanning */ +.qr-scan-container.scanning .qr-scanner-overlay::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background-image: + linear-gradient(rgba(0, 255, 170, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 255, 170, 0.03) 1px, transparent 1px); + background-size: 6px 6px; + opacity: 0.5; + z-index: 4; +} + +/* =========================================== + PHASE 3: Complete - brackets lock solid + =========================================== */ +.qr-scan-container.scan-complete .qr-finder-bracket { + opacity: 1; + border-color: rgba(0, 255, 170, 1); + box-shadow: 0 0 8px rgba(0, 255, 170, 0.6); + animation: none; +} + +/* Data panel at bottom of overlay (relative to image) */ +.qr-scanner-overlay .qr-data-panel { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + background: linear-gradient(to top, + rgba(10, 15, 30, 0.95) 0%, + rgba(10, 15, 30, 0.6) 80%, + transparent 100%); + padding: 4px 6px 3px 6px; + opacity: 0; + transition: opacity 0.3s ease; + border-radius: 0 0 6px 6px; +} + +.qr-scan-container.scan-complete .qr-data-panel { + opacity: 1; +} + +/* Hide the static data panel in HTML */ +.qr-scan-container > .qr-data-panel { + display: none; +} + +/* Hide elements we don't use */ +.qr-scan-container .crop-badge { + display: none; +} + +/* QR Data Panel text styles */ +.qr-data-filename { + font-family: 'Courier New', monospace; + font-size: 0.6rem; + color: #fff; + text-align: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.qr-data-filename i { + color: rgba(0, 255, 170, 1); + margin-right: 3px; +} + +.qr-data-row { + display: flex; + justify-content: space-between; + align-items: center; + font-family: 'Courier New', monospace; + font-size: 0.5rem; + white-space: nowrap; +} + +.qr-status-badge { + display: inline-flex; + align-items: center; + background: rgba(0, 255, 170, 0.15); + border: 1px solid rgba(0, 255, 170, 0.4); + border-radius: 2px; + padding: 1px 4px; + font-size: 0.45rem; + color: rgba(0, 255, 170, 1); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.qr-data-value { + color: rgba(0, 255, 170, 1); + font-weight: 600; + font-size: 0.5rem; +} diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index 648b3f2..e6470f1 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -59,7 +59,8 @@ .qr-crop-container img { display: block; - max-height: 120px; + max-height: 180px; + max-width: 180px; width: auto; margin: 0 auto; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); @@ -75,16 +76,19 @@ left: 50%; transform: translate(-50%, -50%) scale(0.3); opacity: 0; - max-height: 100px; + max-height: 160px; + min-width: 140px; + min-height: 140px; + object-fit: contain; } -.qr-crop-container.animating .qr-original { +.qr-crop-container.scan-complete .qr-original { opacity: 0; transform: scale(1.1); filter: blur(4px); } -.qr-crop-container.animating .qr-cropped { +.qr-crop-container.scan-complete .qr-cropped { opacity: 1; transform: translate(-50%, -50%) scale(1); } @@ -98,7 +102,7 @@ transition: opacity 0.3s ease 0.4s; } -.qr-crop-container.animating .crop-badge { +.qr-crop-container.scan-complete .crop-badge { opacity: 1; } @@ -164,13 +168,37 @@ -
+
Drop image or click to browse
- + + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + image.jpg +
+
+ Hash Acquired + -- +
+
SHA256: ················
+
The same reference photo used for encoding @@ -181,13 +209,36 @@ -
+
Drop image or click to browse
- + + +
+ +
+ +
+
+
+
+
+
+ +
+
+ + image.png +
+
+ Stego Loaded + -- +
+
-- × -- px
+
The image containing the hidden message/file @@ -218,7 +269,7 @@
-
@@ -257,10 +308,20 @@ Drop QR image or click to browse
-
+
Original Cropped QR - Detected + +
+
+ + RSA Key loaded +
+
+ RSA Key + -- +
+
@@ -268,7 +329,7 @@
-
@@ -398,266 +459,55 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/frontends/web/templates/encode.html b/frontends/web/templates/encode.html index 47c4e5d..4b0962b 100644 --- a/frontends/web/templates/encode.html +++ b/frontends/web/templates/encode.html @@ -63,7 +63,8 @@ .qr-crop-container img { display: block; - max-height: 120px; + max-height: 180px; + max-width: 180px; width: auto; margin: 0 auto; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); @@ -79,16 +80,19 @@ left: 50%; transform: translate(-50%, -50%) scale(0.3); opacity: 0; - max-height: 100px; + max-height: 160px; + min-width: 140px; + min-height: 140px; + object-fit: contain; } -.qr-crop-container.animating .qr-original { +.qr-crop-container.scan-complete .qr-original { opacity: 0; transform: scale(1.1); filter: blur(4px); } -.qr-crop-container.animating .qr-cropped { +.qr-crop-container.scan-complete .qr-cropped { opacity: 1; transform: translate(-50%, -50%) scale(1); } @@ -102,7 +106,7 @@ transition: opacity 0.3s ease 0.4s; } -.qr-crop-container.animating .crop-badge { +.qr-crop-container.scan-complete .crop-badge { opacity: 1; } @@ -117,78 +121,42 @@
- -
- - -
- -
- -
- - -
- -
-
- - -
- - LSB for private channels (email, cloud storage). - DCT for social media that recompresses images. -
-
-
-
+
Drop image or click to browse
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + image.jpg +
+
+ Hash Acquired + -- +
+
SHA256: ················
+
The secret photo both parties have (NOT transmitted) @@ -199,13 +167,36 @@ -
+
Drop image or click to browse
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+ + image.jpg +
+
+ Carrier Loaded + -- +
+
-- × -- px
+
The image to hide your message in (e.g., a meme) @@ -221,12 +212,40 @@ Carrier: -
- LSB: - - DCT: - + DCT: - + LSB: -
+ +
+ + +
+ + + + + +
+
+
-
+
Original Cropped QR - Detected + +
+
+ + RSA Key loaded +
+
+ RSA Key + -- +
+
@@ -367,7 +396,7 @@
-
@@ -375,7 +404,7 @@
-
+