proofly / templates /index.html
Pragthedon's picture
Initial backend API deployment
4f48a4e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proofly - Dashboard</title>
<meta name="description"
content="Advanced AI-powered fact-checking system that verifies claims using multiple sources and natural language inference.">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<!-- Apply saved theme immediately to prevent flash -->
<script>if (localStorage.getItem('proofly-theme') === 'dark') document.documentElement.setAttribute('data-theme', 'dark');</script>
<style>
/* OCR Upload Area */
.ocr-upload-area {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
margin-bottom: 0.75rem;
background: rgba(37, 99, 235, 0.04);
border: 1.5px dashed rgba(37, 99, 235, 0.25);
border-radius: 12px;
transition: all 0.2s ease;
}
.ocr-upload-area:hover {
background: rgba(37, 99, 235, 0.07);
border-color: rgba(37, 99, 235, 0.45);
}
.ocr-label {
font-size: 0.82rem;
color: var(--text-muted);
flex: 1;
}
.ocr-label strong {
color: var(--primary);
}
.ocr-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
padding: 0.45rem 0.9rem;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.2s;
white-space: nowrap;
}
.ocr-btn:hover {
background: #1d4ed8;
}
.ocr-btn i {
font-size: 1rem;
}
.ocr-preview-wrap {
display: none;
align-items: center;
gap: 0.6rem;
flex: 1;
}
.ocr-preview-wrap.visible {
display: flex;
}
.ocr-thumb {
width: 38px;
height: 38px;
border-radius: 6px;
object-fit: cover;
border: 2px solid rgba(37, 99, 235, 0.3);
}
.ocr-status-text {
font-size: 0.82rem;
color: var(--text-muted);
}
.ocr-status-text.success {
color: #10b981;
font-weight: 600;
}
.ocr-status-text.error {
color: #ef4444;
font-weight: 600;
}
.ocr-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(37, 99, 235, 0.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 4px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.ocr-clear-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-light);
font-size: 1rem;
padding: 0.2rem;
line-height: 1;
transition: color 0.2s;
}
.ocr-clear-btn:hover {
color: #ef4444;
}
</style>
</head>
<body>
<div class="app-container">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="sidebar-top">
<button class="icon-btn active-icon" title="New Check"><i class="ph ph-plus"></i></button>
<div class="spacer"></div>
<a href="/history" class="nav-btn" title="My History" style="text-decoration:none;"><i
class="ph ph-clock-counter-clockwise"></i></a>
{% if g.is_admin %}
<a href="/admin" class="nav-btn" title="God Mode"
style="text-decoration:none; color: var(--primary);"><i class="ph ph-shield-check"></i></a>
{% endif %}
</div>
<div class="sidebar-bottom">
<button class="theme-toggle-btn" title="Toggle dark / light mode" onclick="toggleTheme()">
<i class="ph ph-moon icon-moon"></i>
<i class="ph ph-sun icon-sun"></i>
</button>
<div class="profile-menu-container">
<div class="profile-btn" onclick="toggleProfileMenu()" title="{{ g.username }}"
style="background: transparent; padding: 0; width: 44px; height: 44px;">
<img src="{{ url_for('static', filename='default_profile.svg') }}" alt="Profile"
style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color); background: var(--bg-input);">
</div>
<div class="profile-dropdown" id="profileDropdown">
<div class="dropdown-header">
<span class="dropdown-username">{{ g.username }}</span>
</div>
<a href="{{ url_for('auth.logout') }}" class="dropdown-item danger">
<i class="ph ph-sign-out"></i> Logout
</a>
</div>
</div>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<!-- Top Header -->
<header class="top-header">
<div class="header-left">
<!-- FactCheck Engine removed -->
</div>
<div class="header-center">
<span class="daily-text">Intelligence Dashboard</span>
</div>
<div class="header-right" style="display:flex; align-items:center; gap:1rem;">
</div>
</header>
<!-- Hero Section -->
<section class="hero-section">
<div class="hero-content">
<h1 class="hero-title">
<span class="greeting">Proofly,</span> <br><span class="main-text">Ready to Verify
Claims?</span>
</h1>
<!-- Action Cards -->
<div class="action-cards">
<div class="card"
onclick="document.getElementById('claimInput').value='The Earth revolves around the Sun';">
<div class="card-icon" style="color: #2563eb;"><i class="ph-fill ph-globe"></i></div>
<p class="card-desc">"The Earth revolves around the Sun"</p>
<span class="card-label">Science Fact</span>
</div>
<div class="card"
onclick="document.getElementById('claimInput').value='Artificial Intelligence was invented in 2020';">
<div class="card-icon" style="color: #EC4899;"><i class="ph-fill ph-cpu"></i></div>
<p class="card-desc">"Artificial Intelligence was invented in 2020"</p>
<span class="card-label">Tech History</span>
</div>
<div class="card"
onclick="document.getElementById('claimInput').value='Water boils at 100 degrees Celsius at sea level';">
<div class="card-icon" style="color: #F59E0B;"><i class="ph-fill ph-drop"></i></div>
<p class="card-desc">"Water boils at 100 degrees Celsius at sea level"</p>
<span class="card-label">General Knowledge</span>
</div>
</div>
</div>
</section>
<!-- Bottom Prompt Area -->
<div class="prompt-container">
<div class="prompt-header">
<span class="pro-text"><i class="ph ph-info"></i> Enter any claim or statement to verify its
authenticity</span>
<span class="powered-text"><i class="ph ph-lightning"></i> Real-time multi-source analysis</span>
</div>
<!-- Hidden file input -->
<input type="file" id="ocrFileInput" accept="image/*,video/mp4,video/quicktime,video/webm"
style="display:none;">
<!-- Preview + status state (initially hidden) -->
<div class="ocr-preview-wrap" id="ocrPreviewWrap"
style="margin-bottom: 0.75rem; padding: 0.75rem; background: var(--bg-input); border-radius: var(--radius-sm); border: 1px solid var(--border-color); flex-wrap: nowrap; align-items: center;">
<img id="ocrThumb" class="ocr-thumb" src="" alt="Preview">
<div id="ocrActionBtns" style="display:flex; gap:0.5rem; flex:1;">
<button type="button" class="ocr-btn" id="btnExtractText"><i class="ph ph-text-t"></i> Extract
Text</button>
<button type="button" class="ocr-btn" id="btnCheckDeepfake" style="background:#8b5cf6;"><i
class="ph ph-scan"></i> Check Authenticity</button>
</div>
<span class="ocr-status-text" id="ocrStatusText" style="flex:1; display:none;"></span>
<button type="button" class="ocr-clear-btn" id="ocrClearBtn" title="Clear image"
style="align-self: center;">
<i class="ph ph-x-circle"></i>
</button>
</div>
<form id="claimForm" class="prompt-form">
<div class="input-wrapper">
<button type="button" class="btn-icon" id="ocrTriggerBtn" title="Upload Image for OCR"><i
class="ph ph-paperclip"></i></button>
<input type="text" id="claimInput" name="claim" class="prompt-input"
placeholder="Example : &quot;The moon landing was faked&quot;">
<button type="submit" id="submitBtn" class="btn-submit"><i
class="ph-fill ph-paper-plane-right"></i></button>
</div>
</form>
<!-- Quick actions removed per request -->
<!-- Loading and Error States -->
<div id="loadingState" class="status-overlay hidden">
<div class="spinner-ring"></div>
<p>Analyzing claim and gathering evidence...</p>
</div>
<div id="errorState" class="status-overlay error-overlay hidden">
<p class="error-text"></p>
<button type="button" class="retry-btn" onclick="resetForm()">Try Again</button>
</div>
</div>
<!-- Deepfake Analysis Modal -->
<div id="dfModal" class="status-overlay hidden"
style="position: fixed; z-index: 1000; background: rgba(0,0,0,0.6); backdrop-filter: blur(10px); display:flex; padding: 2rem;">
<div class="df-modal-content"
style="background: var(--bg-card); max-width: 900px; width: 100%; max-height: 90vh; overflow-y: auto; border-radius: var(--radius-md); border: 1px solid var(--border-color); box-shadow: var(--app-shadow); position: relative; display: flex; flex-direction: column;">
<button type="button" onclick="closeDfModal()"
style="position: absolute; top: 1.5rem; right: 1.5rem; background: transparent; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; z-index: 10;"><i
class="ph ph-x"></i></button>
<div id="dfModalBody" style="padding: 2rem;"></div>
</div>
</div>
</main>
</div>
<!-- Scripts for Logic -->
<script>
function toggleTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
if (isDark) {
document.documentElement.removeAttribute('data-theme');
localStorage.setItem('proofly-theme', 'light');
} else {
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem('proofly-theme', 'dark');
}
}
const form = document.getElementById('claimForm');
const input = document.getElementById('claimInput');
const loadingState = document.getElementById('loadingState');
const errorState = document.getElementById('errorState');
// --- OCR Logic ---
const ocrFileInput = document.getElementById('ocrFileInput');
const ocrTriggerBtn = document.getElementById('ocrTriggerBtn');
const ocrPreviewWrap = document.getElementById('ocrPreviewWrap');
const ocrThumb = document.getElementById('ocrThumb');
const ocrStatusText = document.getElementById('ocrStatusText');
const ocrClearBtn = document.getElementById('ocrClearBtn');
const ocrActionBtns = document.getElementById('ocrActionBtns');
const btnExtractText = document.getElementById('btnExtractText');
const btnCheckDeepfake = document.getElementById('btnCheckDeepfake');
const dfModal = document.getElementById('dfModal');
const dfModalBody = document.getElementById('dfModalBody');
function closeDfModal() {
dfModal.classList.add('hidden');
}
ocrTriggerBtn.addEventListener('click', () => ocrFileInput.click());
ocrClearBtn.addEventListener('click', () => {
ocrFileInput.value = '';
ocrPreviewWrap.classList.remove('visible');
ocrThumb.src = '';
ocrStatusText.textContent = '';
ocrStatusText.style.display = 'none';
ocrActionBtns.style.display = 'flex';
});
ocrFileInput.addEventListener('change', () => {
const file = ocrFileInput.files[0];
if (!file) return;
// Enforce max 20MB limit immediately on the client side
const maxVideoSize = 20 * 1024 * 1024;
if (file.type.startsWith('video/') && file.size > maxVideoSize) {
alert("Video exceeds the 20MB maximum size limit. Please upload a smaller valid clip.");
ocrFileInput.value = '';
return;
}
// Show preview immediately, wait for user action
const objectUrl = URL.createObjectURL(file);
// If it's a video, adjust the preview dynamically and hide the "Extract Text" button
if (file.type.startsWith('video/')) {
// Try create a generic video preview or icon
ocrThumb.src = objectUrl;
ocrThumb.outerHTML = `<video id="ocrThumb" class="ocr-thumb" src="${objectUrl}" muted autoplay playsinline loop></video>`;
// Need to re-grab reference if mutated
document.getElementById('btnExtractText').style.display = 'none';
} else {
// Handle switching back to image from a previous video preview state
const thumbElem = document.getElementById('ocrThumb');
if (thumbElem.tagName === 'VIDEO') {
thumbElem.outerHTML = `<img id="ocrThumb" class="ocr-thumb" src="${objectUrl}" alt="Preview">`;
} else {
thumbElem.src = objectUrl;
}
document.getElementById('btnExtractText').style.display = 'inline-flex';
}
ocrPreviewWrap.classList.add('visible');
ocrStatusText.style.display = 'none';
ocrActionBtns.style.display = 'flex';
});
btnExtractText.addEventListener('click', async () => {
const file = ocrFileInput.files[0];
if (!file) return;
ocrActionBtns.style.display = 'none';
ocrStatusText.style.display = 'block';
ocrStatusText.className = 'ocr-status-text';
ocrStatusText.innerHTML = '<span class="ocr-spinner"></span> Extracting text from image…';
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/ocr', { method: 'POST', body: formData });
const data = await response.json();
if (data.success && data.text) {
input.value = data.text;
ocrStatusText.className = 'ocr-status-text success';
ocrStatusText.innerHTML = '<i class="ph ph-check-circle"></i> Text extracted — claim auto-filled!';
} else if (data.success && !data.text) {
ocrStatusText.className = 'ocr-status-text error';
ocrStatusText.innerHTML = '<i class="ph ph-warning"></i> No text found in image.';
} else {
ocrStatusText.className = 'ocr-status-text error';
ocrStatusText.innerHTML = '<i class="ph ph-warning"></i> ' + (data.error || 'Could not process image.');
}
} catch (err) {
ocrStatusText.className = 'ocr-status-text error';
ocrStatusText.innerHTML = '<i class="ph ph-warning"></i> Network error during OCR.';
}
});
btnCheckDeepfake.addEventListener('click', async () => {
const file = ocrFileInput.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const endpoint = isVideo ? '/api/verify_video' : '/api/verify_image';
const fileKey = isVideo ? 'video' : 'image';
ocrActionBtns.style.display = 'none';
ocrStatusText.style.display = 'block';
ocrStatusText.className = 'ocr-status-text';
ocrStatusText.innerHTML = '<span class="ocr-spinner"></span> ' + (isVideo ? 'Analyzing video frames...' : 'Running 5-Model Ensemble Deepfake Check...');
try {
const formData = new FormData();
formData.append(fileKey, file);
const response = await fetch(endpoint, { method: 'POST', body: formData });
const data = await response.json();
if (data.success) {
ocrStatusText.className = 'ocr-status-text success';
ocrStatusText.innerHTML = '<i class="ph ph-check-circle"></i> Authenticity check complete!';
// Render DF Modal
renderDfModal(data, isVideo);
dfModal.classList.remove('hidden');
} else {
ocrStatusText.className = 'ocr-status-text error';
ocrStatusText.innerHTML = '<i class="ph ph-warning"></i> ' + (data.error || 'Analysis failed.');
}
} catch (err) {
ocrStatusText.className = 'ocr-status-text error';
ocrStatusText.innerHTML = '<i class="ph ph-warning"></i> Network error during analysis.';
}
});
function renderDfModal(data, isVideo = false) {
const isFake = data.label === 'FAKE';
const isReal = data.label === 'REAL';
const badgeColor = isFake ? '#ef4444' : (isReal ? '#10b981' : '#f59e0b');
const wrapClass = isFake ? 'fake-wrap' : (isReal ? 'real-wrap' : 'uncertain-wrap');
const mediaText = isVideo ? (isFake ? "FAKE VIDEO" : "AUTHENTIC VIDEO") : (isFake ? "FAKE IMAGE" : "AUTHENTIC PHOTO");
let scoresHtml = '';
const models = [
{ id: 'hf_primary', name: 'AI Detector (ViT)', weight: 35 },
{ id: 'hf_secondary', name: 'Deepfake Det (ViT)', weight: 25 },
{ id: 'clip', name: 'CLIP Semantics', weight: 20 },
{ id: 'frequency', name: 'Frequency / Noise', weight: 15 },
{ id: 'cnn', name: 'CNN EfficientNet', weight: 5 }
];
models.forEach(m => {
const p = data.scores && data.scores[m.id] ? data.scores[m.id] : 0;
scoresHtml += `
<div style="display:flex; align-items:center; gap: 1rem; margin-bottom: 0.75rem;">
<div style="width: 140px; font-size: 0.85rem; font-weight:600; color:var(--text-main);">${m.name}</div>
<div style="flex:1; height: 8px; background: var(--border-color); border-radius: 4px; overflow:hidden;">
<div style="height:100%; width: ${p * 100}%; background: ${p > 0.5 ? '#ef4444' : '#10b981'};"></div>
</div>
<div style="width: 50px; text-align:right; font-family: monospace; font-size:0.85rem;">${(p * 100).toFixed(1)}%</div>
</div>
`;
});
dfModalBody.innerHTML = `
<div style="display:flex; gap: 2rem; flex-wrap: wrap;">
<div style="flex:1; min-width:300px;">
<div style="margin-bottom: 2rem;">
<h2 style="font-size:1.8rem; margin-bottom:0.5rem; color:${badgeColor}; display:flex; align-items:center; gap:0.5rem;">
${isFake ? '<i class="ph-fill ph-warning-circle"></i> ' + mediaText : (isReal ? '<i class="ph-fill ph-check-circle"></i> ' + mediaText : '<i class="ph-fill ph-question"></i> UNCERTAIN')}
</h2>
<p style="color:var(--text-muted); font-size:1.1rem; margin-bottom: 1rem;">
The ensemble is <strong>${isFake ? (data.fake_prob * 100).toFixed(1) + '% confident' : (data.real_prob * 100).toFixed(1) + '% confident'}</strong>.
</p>
<div style="border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 1.5rem; margin-bottom: 1.5rem; background: var(--bg-input);">
<h4 style="margin-bottom:1rem; font-size:0.9rem; text-transform:uppercase; color:var(--text-muted);">Ensemble Votes</h4>
${scoresHtml}
</div>
<div style="font-size:0.95rem; color:var(--text-muted); white-space:pre-wrap; line-height:1.6; padding:1rem; background:rgba(37,99,235,0.05); border-radius:var(--radius-sm);">
${data.explanation}
</div>
</div>
</div>
${data.gradcam_b64 ? `
<div style="width:340px; border-radius: var(--radius-sm); overflow:hidden; border: 1px solid var(--border-color); background:#000; align-self:flex-start;">
<h4 style="background:var(--bg-input); padding: 0.75rem 1rem; margin:0; font-size:0.85rem; border-bottom:1px solid var(--border-color); color:var(--text-main);">CNN GradCAM Heatmap</h4>
<img src="data:image/png;base64,${data.gradcam_b64}" style="width:100%; display:block; object-fit:contain; max-height:400px;" alt="GradCAM">
<div style="padding:0.75rem 1rem; font-size:0.8rem; color:var(--text-muted); background:var(--bg-input);">Shows regions that triggered the CNN classifier most strongly. High activations often correspond to AI generation artifacts.</div>
</div>` : ''}
</div>
`;
}
// --- Form Submit Logic ---
function resetForm() {
errorState.classList.add('hidden');
loadingState.classList.add('hidden');
form.querySelector('.input-wrapper').style.opacity = '1';
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const claim = input.value.trim();
if (!claim) {
showError('Please enter a claim to verify');
return;
}
// Show loading state
form.querySelector('.input-wrapper').style.opacity = '0.5';
errorState.classList.add('hidden');
loadingState.classList.remove('hidden');
try {
const formData = new FormData();
formData.append('claim', claim);
const response = await fetch('/check', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
window.location.href = '/results';
} else {
showError(data.error || 'Failed to verify claim');
}
} catch (error) {
showError('Network error. Please try again.');
}
});
function showError(message) {
loadingState.classList.add('hidden');
errorState.classList.remove('hidden');
errorState.querySelector('.error-text').textContent = message;
form.querySelector('.input-wrapper').style.opacity = '1';
}
function toggleProfileMenu() {
const menu = document.getElementById('profileDropdown');
if (menu) menu.classList.toggle('open');
}
document.addEventListener('click', (e) => {
const container = document.querySelector('.profile-menu-container');
const menu = document.getElementById('profileDropdown');
if (container && menu && !container.contains(e.target)) {
menu.classList.remove('open');
}
});
// --- Suggested Facts Logic (Cards) ---
function updateSuggestedFacts() {
fetch('/api/suggested_facts')
.then(res => res.json())
.then(data => {
if (data.success && data.facts.length === 3) {
const cards = document.querySelectorAll('.action-cards .card');
if (cards.length === 3) {
cards.forEach((card, i) => {
// Add smooth transition
card.style.transition = 'opacity 0.6s ease';
card.style.opacity = '0';
setTimeout(() => {
card.setAttribute('onclick', `document.getElementById('claimInput').value='${data.facts[i].replace(/'/g, "\\\\\\'")}';`);
const desc = card.querySelector('.card-desc');
if (desc) desc.textContent = `"${data.facts[i]}"`;
card.style.opacity = '1';
}, 600); // match transition time
});
}
}
})
.catch(err => console.error("Error fetching suggested facts:", err));
}
// Update every 5 minutes (300000ms)
setInterval(updateSuggestedFacts, 300000);
</script>
</body>
</html>