Contra-Signal / frontend /static /js /analyze.js
GSMSB's picture
deploy: fresh start with clean history
a78209f
Raw
History Blame Contribute Delete
15.6 kB
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const form = document.getElementById('analysisForm');
const companyInput = document.getElementById('ticker');
const fileInput = document.getElementById('mainReport');
const fileZone = document.getElementById('mainDropZone'); // This is the <label>
const submitBtn = document.getElementById('submitBtn');
const suggestionsList = document.getElementById('ticker-suggestions');
const readyIndicator = document.getElementById('companyReadyIndicator');
// Store original content to restore it later
// We target the first child div which contains the visual elements
const originalContent = fileZone ? fileZone.firstElementChild.innerHTML : '';
if (!form || !companyInput || !fileInput || !fileZone || !submitBtn) {
console.error("Critical elements missing in DOM");
return;
}
// --- Autocomplete Logic ---
const setupAutocomplete = (inputEl, listEl, indicatorEl, onValidateState) => {
let debounceTimer;
inputEl.addEventListener('input', (e) => {
const query = e.target.value.trim();
// INVALIDATE on any typing
onValidateState(false);
if (indicatorEl) indicatorEl.classList.remove('opacity-100');
validateForm();
clearTimeout(debounceTimer);
if (query.length < 2) {
hideSuggestions(listEl);
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const matches = await res.json();
renderSuggestions(matches, inputEl, listEl, indicatorEl, onValidateState);
} catch (err) {
console.error("Search failed", err);
}
}, 300);
});
// Hide suggestions when clicking outside
document.addEventListener('click', (e) => {
if (!inputEl.contains(e.target) && !listEl.contains(e.target)) {
hideSuggestions(listEl);
}
});
};
function renderSuggestions(matches, inputEl, listEl, indicatorEl, onValidateState) {
listEl.innerHTML = '';
if (!matches || matches.length === 0) {
hideSuggestions(listEl);
return;
}
matches.forEach(name => {
const li = document.createElement('li');
li.className = "px-6 py-3 cursor-pointer hover:bg-white/5 transition-colors text-gray-300 hover:text-primary font-mono text-sm flex items-center gap-2";
li.innerHTML = `<span class="material-symbols-outlined text-xs opacity-50">show_chart</span> ${name}`;
li.addEventListener('click', () => {
inputEl.value = name;
// VALIDATE on click
onValidateState(true);
if (indicatorEl) indicatorEl.classList.add('opacity-100');
hideSuggestions(listEl);
validateForm();
});
listEl.appendChild(li);
});
listEl.classList.remove('hidden');
}
function hideSuggestions(listEl) {
listEl.classList.add('hidden');
}
// Main Ticker Setup
let isTickerValid = false;
setupAutocomplete(companyInput, suggestionsList, readyIndicator, (isValid) => { isTickerValid = isValid; });
// --- Dynamic Competitor Logic ---
const addCompetitorBtn = document.getElementById('addCompetitorBtn');
const competitorsList = document.getElementById('competitorsList');
const manualCompetitorsJson = document.getElementById('manualCompetitorsJson');
// Track validity of each row by ID
const competitorValidityMap = new Map();
if (addCompetitorBtn && competitorsList) {
addCompetitorBtn.addEventListener('click', () => {
// MAX 3 Limit
if (competitorsList.children.length >= 5) {
// Shake button or show toast? Just alert for now or ignore
return;
}
addCompetitorRow();
});
}
function addCompetitorRow() {
const rowId = 'comp-' + Date.now();
const row = document.createElement('div');
row.className = "relative group animate-in fade-in slide-in-from-top-2 duration-300";
row.id = rowId;
// Mark as invalid initially if empty, but we treat empty as valid in final check
// However, if user types, it MUST be valid.
competitorValidityMap.set(rowId, false);
row.innerHTML = `
<input
class="block py-3 px-0 w-full text-xl md:text-2xl font-mono text-white bg-transparent border-0 border-b-2 border-dashed border-gray-700 appearance-none focus:outline-none focus:ring-0 focus:border-primary peer transition-colors placeholder-transparent tracking-widest pr-10"
id="input-${rowId}" placeholder=" " type="text" autocomplete="off" />
<label
class="peer-focus:font-medium absolute text-xs md:text-sm text-gray-500 duration-300 transform -translate-y-6 scale-75 top-3 -z-10 origin-[0] peer-focus:start-0 peer-focus:text-primary peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-6"
for="input-${rowId}">
COMPETITOR ${competitorsList.children.length + 1}
</label>
<div id="ready-${rowId}"
class="absolute right-0 bottom-3 opacity-0 transition-opacity flex items-center gap-2 text-primary font-mono text-xs pointer-events-none">
<span class="material-symbols-outlined text-base">check_circle</span>
</div>
<!-- Remove Button (Hover only) -->
<button type="button" class="absolute -right-8 top-3 text-red-500 opacity-0 group-hover:opacity-100 transition-opacity hover:scale-110"
onclick="removeCompetitorRow('${rowId}')">
<span class="material-symbols-outlined">close</span>
</button>
<!-- Autocomplete List -->
<ul id="list-${rowId}"
class="absolute z-50 w-full mt-2 bg-surface-dark/95 backdrop-blur-xl border border-white/10 rounded-lg shadow-[0_10px_40px_-10px_rgba(0,0,0,0.8)] hidden max-h-60 overflow-y-auto overflow-x-hidden divide-y divide-white/5 no-scrollbar">
</ul>
`;
competitorsList.appendChild(row);
const inputEl = row.querySelector('input');
const listEl = row.querySelector('ul');
const readyEl = row.querySelector(`#ready-${rowId}`);
// Attach Autocomplete
setupAutocomplete(inputEl, listEl, readyEl, (isValid) => {
competitorValidityMap.set(rowId, isValid);
updateCompetitorJson(); // Update hidden form field
});
// Add focus immediately
inputEl.focus();
validateForm();
// Re-check Max Limit to disable Add button style? (Optional)
}
// Global function for onclick to work
window.removeCompetitorRow = function (rowId) {
const row = document.getElementById(rowId);
if (row) {
row.remove();
competitorValidityMap.delete(rowId);
updateCompetitorJson();
validateForm();
}
}
function updateCompetitorJson() {
// Collect all VALID inputs
const names = [];
competitorsList.querySelectorAll('input').forEach(input => {
const val = input.value.trim();
// We assume if it's in the validity map as TRUE, it's a valid ticker
// But we actually trust the validity map more.
// However, get the ID from parent
const rowId = input.parentElement.id;
if (val.length > 0 && competitorValidityMap.get(rowId)) {
names.push(val);
}
});
manualCompetitorsJson.value = names.join(',');
}
// -----------------------
// 1. Drag and Drop Logic
// Prevent default behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
fileZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight styles
['dragenter', 'dragover'].forEach(eventName => {
fileZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
fileZone.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
fileZone.classList.add('border-primary', 'bg-black/40', 'shadow-[inset_0_0_20px_rgba(51,242,13,0.1)]');
fileZone.classList.remove('bg-black/20');
}
function unhighlight(e) {
fileZone.classList.remove('border-primary', 'bg-black/40', 'shadow-[inset_0_0_20px_rgba(51,242,13,0.1)]');
fileZone.classList.add('bg-black/20');
}
// Handle Drop
fileZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length) {
fileInput.files = files; // Assign files to input
handleFiles(files);
}
});
// Handle File Input Change (Click selection)
fileInput.addEventListener('change', function () {
if (this.files.length) {
handleFiles(this.files);
}
});
function handleFiles(files) {
const file = files[0];
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
updateFileDisplay(file);
} else {
alert("Please upload a PDF file only.");
resetFile();
}
}
function updateFileDisplay(file) {
const contentDiv = fileZone.firstElementChild;
if (!contentDiv) return;
if (file) {
// Active State
fileZone.classList.add('border-primary', 'bg-primary/5');
fileZone.classList.remove('border-gray-700');
contentDiv.innerHTML = `
<div class="flex flex-col items-center justify-center pt-5 pb-6 text-center w-full h-full relative z-20 pointer-events-none">
<div class="flex items-center gap-3 text-[#33f20d] font-bold bg-[#33f20d]/10 px-6 py-3 rounded-full border border-[#33f20d]/30 pointer-events-auto">
<span class="material-symbols-outlined">description</span>
<span class="truncate max-w-[200px]">${file.name}</span>
<div id="removeFileBtn" class="ml-2 hover:bg-[#33f20d]/20 rounded-full p-1 transition-colors flex items-center justify-center cursor-pointer">
<span class="material-symbols-outlined text-sm text-red-400">close</span>
</div>
</div>
<p class="text-xs text-gray-500 font-mono mt-2">READY TO SCAN</p>
</div>
`;
// Re-attach listener to the new remove button
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
const removeBtn = document.getElementById('removeFileBtn');
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // Stop bubbling so Label click isn't triggered
resetFile();
});
}
}, 0);
} else {
// Default State
resetUI();
}
validateForm();
}
function resetFile() {
fileInput.value = ''; // Clear input
resetUI();
validateForm();
}
function resetUI() {
const contentDiv = fileZone.firstElementChild;
if (!contentDiv) return;
fileZone.classList.remove('border-primary', 'bg-primary/5');
fileZone.classList.add('border-gray-700');
// Restore original HTML content
contentDiv.innerHTML = originalContent;
// Ensure "fileInfo" is hidden if it was in the original content
const fileInfo = document.getElementById('fileInfo');
if (fileInfo) fileInfo.classList.add('hidden');
}
// 2. Validation Logic
[companyInput, fileInput].forEach(el => {
// NOTE: We don't use 'input' listener here for companyInput anymore
// because we handle it in the debounce logic above, but keeping fileInput checks is fine.
// Actually, we just need to ensure validateForm is called.
// It is called in the debounce input handler, so we can leave fileInput here.
if (el === fileInput) {
el.addEventListener('change', validateForm);
}
});
function validateForm() {
// STRICT CHECK: isTickerValid must be true
const hasCompany = isTickerValid;
// Check files length
const hasFile = fileInput.files && fileInput.files.length > 0;
// Competitor Check (Multi-Row):
let isCompetitorSafe = true;
// Iterate all active inputs
if (competitorsList) {
const inputs = competitorsList.querySelectorAll('input');
inputs.forEach(input => {
const val = input.value.trim();
const rowId = input.parentElement.id;
const isValid = competitorValidityMap.get(rowId);
// If has text, MUST be valid
if (val.length > 0 && !isValid) {
isCompetitorSafe = false;
}
});
}
if (hasCompany && hasFile && isCompetitorSafe) {
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
submitBtn.classList.add('shadow-neon', 'hover:scale-105');
} else {
submitBtn.disabled = true;
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
submitBtn.classList.remove('shadow-neon', 'hover:scale-105');
}
}
// 3. Form Submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
const btnContent = submitBtn.querySelector('span'); // The span wrapping text
const originalBtnHTML = btnContent.innerHTML;
btnContent.innerHTML = `<span class="material-symbols-outlined animate-spin">refresh</span> UPLOADING...`;
const formData = new FormData(form);
try {
const response = await fetch('/api/analyze', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
window.location.href = `/progress/${data.job_id}`;
} else {
alert('Analysis failed: ' + (data.detail || 'Unknown error'));
submitBtn.disabled = false;
btnContent.innerHTML = originalBtnHTML;
}
} catch (error) {
console.error(error);
alert('Server Error. Check console.');
submitBtn.disabled = false;
btnContent.innerHTML = originalBtnHTML;
}
});
// Initial check
validateForm();
});