old app_core

Modified: February 14, 2026 7:58 PM Created: January 6, 2026 5:49 PM Master Type: Notes Hide: No Starred: No Status: Unassigned

/**
 * AppCore — NovaWeb-Aligned
 */

class CatalogueApp {
    constructor() {
        this.selectedIds = new Set();
        this.lastSelectedId = null;
        this.focusId = null;
        this.bucket = new Set();
        this.focusMode = false;
        this.focusIdleMinutes = 10;
        this.focusTimer = null;
        this.focusClockTimer = null;
        this.focusOverlay = null;
        this.focusTasks = null;
        this.focusTaskIndex = 0;
        this.taskRotationTick = 0;
        this.lastTaskMinute = null;

        document.addEventListener('DOMContentLoaded', () => {
            const isCollapsed = localStorage.getItem('novaweb_bucket_collapsed') === 'true';
            const bucketHud = document.getElementById('bucket-hud');
            if (isCollapsed && bucketHud) {
                bucketHud.classList.add('collapsed');
            }
            this.init();
        });

        document.addEventListener('keydown', e => this.handleKeydown(e));

        // Activity resets focus timer
        ['mousemove','mousedown','wheel','touchstart'].forEach(evt =>
            document.addEventListener(evt, () => this.resetFocusTimer(), { passive: true })
        );
    }

    init() {
        // WHISKERS STARTUP - Consolidated Gatekeeper
        const whiskers = document.getElementById('whiskers-bar');
        if (whiskers) {
            // Set the scroll duration
            whiskers.style.animation = 'whiskers-scroll 25s linear infinite';
            
            // This event listener handles the swap ONLY when text is off-screen
            whiskers.addEventListener('animationiteration', () => {
                if (whiskers.dataset.pendingText) {
                    whiskers.innerText = whiskers.dataset.pendingText;
                    whiskers.dataset.pendingText = ""; 
                }
            });
        }

        this.syncState();
        this.updateSelectionUI();
        this.autoRefreshPreviews(); // Start the background loader
    }
    
// ============================================================
// Focus Mode
// ============================================================

initFocusOverlay() {
    const overlay = document.createElement('div');
    overlay.id = 'focus-overlay';
    overlay.style.display = 'none';
    overlay.innerHTML = `
        <div class="focus-stack">
            <div id="focus-time" class="focus-time"></div>
            <div id="focus-date" class="focus-date"></div>
            <div id="focus-whiskers" class="focus-whiskers"></div>
            <div id="focus-task" class="focus-task"></div>
        </div>
    `;
    document.body.appendChild(overlay);
    this.focusOverlay = overlay;

    fetch('/api/focus_tasks')
        .then(r => r.ok ? r.json() : null)
        .then(j => { this.focusTasks = j; })
        .catch(() => { this.focusTasks = null; });
}

enterFocusMode() {
    if (this.focusMode) return;
    this.focusMode = true;

    document.body.classList.add('focus-mode');
    if (this.focusOverlay) this.focusOverlay.style.display = 'flex';

    this.updateFocusOverlay();

    // 1s clock for UI updates + Decoupled Task Rotation Check
    this.focusClockTimer = setInterval(() => {
        this.updateFocusOverlay();
        
        const now = new Date();
        const currentMin = now.getMinutes();

        // DECOUPLED: Task rotation is tied to minute-boundary crossing, not a fixed system clock schedule
        if (this.lastTaskMinute !== null && currentMin !== this.lastTaskMinute) {
            this.taskRotationTick++;
            if (this.taskRotationTick % 1 === 0) {
                this.rotateFocusTask();
            }
        }
        this.lastTaskMinute = currentMin;
    }, 1000);
}

exitFocusMode() {
    if (!this.focusMode) return;
    this.focusMode = false;

    document.body.classList.remove('focus-mode');
    if (this.focusOverlay) this.focusOverlay.style.display = 'none';

    clearInterval(this.focusClockTimer);
    clearTimeout(this.focusTaskTimer);

    this.focusClockTimer = null;
    this.focusTaskTimer = null;

    this.resetFocusTimer();
}

resetFocusTimer() {
    if (this.focusMode) { this.exitFocusMode(); return; }
    if (this.focusTimer) clearTimeout(this.focusTimer);
    this.focusTimer = setTimeout(
        () => this.enterFocusMode(),
        this.focusIdleMinutes * 60 * 1000
    );
}

rotateFocusTask() {
    const taskEl = document.getElementById('focus-task');
    if (!taskEl || !this.focusTasks) return;

    const pool =
        (this.focusTasks.today && this.focusTasks.today.length) ? this.focusTasks.today :
        (this.focusTasks.notes && this.focusTasks.notes.length) ? this.focusTasks.notes :
        [];

    if (!pool.length) return;

    taskEl.innerText = pool[this.focusTaskIndex % pool.length];
    this.focusTaskIndex++;
}

updateFocusOverlay() {
    const now = new Date();

    const timeEl  = document.getElementById('focus-time');
    const dateEl  = document.getElementById('focus-date');
    const whiskEl = document.getElementById('focus-whiskers');

    if (timeEl) {
        timeEl.innerText = now.toLocaleTimeString([], {
            hour: '2-digit',
            minute: '2-digit'
        });
    }

    if (dateEl) {
        dateEl.innerText = now.toLocaleDateString(undefined, {
            weekday: 'long',
            year: 'numeric',
            month: 'long',
            day: 'numeric'
        });
    }

    if (whiskEl) {
        const bar = document.getElementById('whiskers-bar');
        whiskEl.innerText = bar ? bar.innerText : '';
        whiskEl.style.color = bar ? bar.style.color : 'var(--accent)';
    }
}

    autoRefreshPreviews() {
    const placeholders = Array.from(document.querySelectorAll('.asset-card'))
        .filter(card => card.innerText.includes('Generating...'));

    if (placeholders.length > 0) {
        placeholders.forEach(card => {
            const id = card.dataset.id;
            const imgUrl = `/preview/${encodeURIComponent(id)}.jpg`;
            
            const imgTest = new Image();
            imgTest.onload = () => {
                const hitbox = card.querySelector('.preview-hitbox');
                const placeholder = hitbox.querySelector('div:not(.badge):not(.label-dot)');
                if (placeholder) {
                    const img = document.createElement('img');
                    img.src = imgUrl;
                    img.className = "w-full h-full object-contain transition-transform duration-500 group-hover:scale-110";
                    img.setAttribute('loading', 'lazy');
                    hitbox.replaceChild(img, placeholder);
                }
            };
            imgTest.src = imgUrl;
        });
    }
    // Check every 5 seconds
    setTimeout(() => this.autoRefreshPreviews(), 5000);
}

    toggleBucket() {
        const bucket = document.getElementById('bucket-hud');
        if (!bucket) return;
        bucket.classList.toggle('collapsed');
        localStorage.setItem('novaweb_bucket_collapsed', bucket.classList.contains('collapsed'));
    }

    applyLabel(labelName) {
        const targets = this.selectedIds.size ? Array.from(this.selectedIds) : (this.focusId ? [this.focusId] : []);
        if (!targets.length) return;

        targets.forEach(id => {
            fetch('/api/label', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({ id: id, label: labelName })
            }).then(() => {
                const card = document.querySelector(`.asset-card[data-id="${id}"]`);
                if (card) {
                    card.dataset.label = labelName;
                    const previewHitbox = card.querySelector('.preview-hitbox');
                    let dot = card.querySelector('.label-dot');

                    if (labelName === 'none' || !labelName) {
                        if (dot) dot.remove();
                    } else {
                        if (!dot) {
                            dot = document.createElement('div');
                            previewHitbox.appendChild(dot);
                        }
                        dot.className = `label-dot label-${labelName}`;
                    }
                }
            });
        });
        this.toast(`Applied ${labelName.toUpperCase()} label`);
    }

    filterByLabel(labelName) {
        const cards = document.querySelectorAll('.asset-card');
        cards.forEach(card => {
            if (labelName === 'all') {
                card.style.display = '';
            } else {
                card.style.display = card.dataset.label === labelName ? '' : 'none';
            }
        });
        this.updateStats();
    }

    clearFlags() {
        if (this.selectedIds.size > 0) {
            this.batchRating('0');
        } else if (this.focusId) {
            this.submitRating(this.focusId, '0');
        }
        this.toast("FLAGS CLEARED");
    }

    runJob(key) {
        // Requirement 3: Separate "Selected" vs "Everything" logic
        const ids = this.selectedIds.size > 0 ? Array.from(this.selectedIds) : [];
        const modeLabel = ids.length > 0 ? `SELECTED ASSETS (${ids.length})` : "ALL ASSETS";

        fetch('/api/run_action', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({ key, ids })
        }).then(() => {
            this.toast(`${key.toUpperCase()} INITIATED ON ${modeLabel}`);
            // Only clear selection if we actually used it for the job
            if (ids.length > 0) this.clearSelection();
        });
    }

    handleKeydown(e) {
        if (!e || ['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;

        const k = (e.key || '').toLowerCase();
        if (k === '0') { 
            e.preventDefault(); 
            this.clearFlags(); 
            return; 
        }

        if (this.focusMode) {
            this.exitFocusMode();
            return;
        }

        if (k === 'f') {
            e.preventDefault();
            this.enterFocusMode();
            return;
        }

        if (k === '/') { e.preventDefault(); document.getElementById('search')?.focus(); return; }
        if (k === 'escape') { this.closeLightbox(); this.clearSelection(); return; }
        
        if (k === ' ') { 
            e.preventDefault(); 
            const lb = document.getElementById('lb');
            if (lb && !lb.classList.contains('hidden')) {
                this.closeLightbox();
            } else if (this.focusId) {
                this.openLightbox(this.focusId);
            }
            return; 
        }

        if (k === 'o') { this.runJob('organize'); return; }

        const ratingMap = {
            '1': 'pass', 'n': 'pass',
            '2': 'fix',  'm': 'fix',
            '3': 'skip', 's': 'skip',
            '4': 'reshoot',
            '5': 'best',
            '6': 'other',
            '0': '0' 
        };

        if (ratingMap[k]) {
            const action = ratingMap[k];
            if (this.focusId && !this.selectedIds.size) {
                this.rateAndAdvance(action);
            } else if (this.selectedIds.size) {
                this.batchRating(action);
            }
            return;
        }

        if (k === 'j') { this.jumpNextUnrated(); return; }
        if (k === 't') { this.scrollToTop(); return; }
        if (k === 'b') { this.scrollToBottom(); return; }

        if ((e.metaKey || e.ctrlKey) && k === 'a') {
            e.preventDefault();
            document.querySelectorAll('.asset-card').forEach(c => {
                if (c.style.display !== 'none') this.selectedIds.add(c.dataset.id);
            });
            this.updateSelectionUI();
        }

        if (e.key === 'ArrowRight' && this.focusId) { e.preventDefault(); this.navigateLightbox(1); return; }
        if (e.key === 'ArrowLeft'  && this.focusId) { e.preventDefault(); this.navigateLightbox(-1); return; }
    }

    submitRating(id, rating) {
        if (!id) return;
        fetch('/api/rate', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ id, rating })
        }).then(() => {
            const card = document.querySelector(`.asset-card[data-id="${id}"]`);
            if (card) {
                card.dataset.rating = encodeURIComponent(rating);
                card.classList.remove('state-pass','state-fix','state-skip','state-reshoot', 'state-best', 'state-other');
                card.classList.add(`state-${rating}`);
            }
            if (rating === 'fix' || rating === 'reshoot') this.addToBucket(id);
            else this.removeFromBucket(id);
            this.toast(`SET: ${rating.toUpperCase()}`);
            this.updateStats();
        });
    }

    rateAndAdvance(status) {
        const id = this.focusId;
        if (!id) return;

        fetch('/api/rate', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ id, rating: status })
        }).then(() => {
            const card = document.querySelector(`.asset-card[data-id="${id}"]`);
            if (card) {
                card.dataset.rating = encodeURIComponent(status);
                card.classList.remove('state-pass','state-fix','state-skip','state-reshoot', 'state-best', 'state-other');
                card.classList.add(`state-${status}`);
            }
            if (status === 'fix' || status === 'reshoot') this.addToBucket(id);
            else this.removeFromBucket(id);

            const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none');
            const ids = cards.map(c => c.dataset.id);
            const i = ids.indexOf(id);
            const next = ids[i + 1];
            if (!next) { this.closeLightbox(); return; }
            this.focusId = next;
            const img = document.getElementById('lb-img');
            if (img) img.src = `/preview-hi/${encodeURIComponent(next)}.jpg`;
            this.updateStats();
        });
    }

    batchRating(status) {
        if (!this.selectedIds.size) return;
        const tasks = Array.from(this.selectedIds).map(id =>
            fetch('/api/rate', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id, rating: status }) })
        );
        Promise.all(tasks).then(() => {
            this.selectedIds.forEach(id => {
                if (status === 'fix' || status === 'reshoot') this.addToBucket(id);
                else this.removeFromBucket(id);
                const card = document.querySelector(`.asset-card[data-id="${id}"]`);
                if (card) {
                    card.dataset.rating = encodeURIComponent(status);
                    card.classList.remove('state-pass','state-fix','state-skip','state-reshoot', 'state-best', 'state-other');
                    card.classList.add(`state-${status}`);
                }
            });
            this.clearSelection();
            this.toast(`BATCH: ${status.toUpperCase()}`);
            this.updateStats();
        });
    }

    handleCardClick(id, e) {
        if (e?.stopPropagation) e.stopPropagation();
        if (e?.shiftKey && this.lastSelectedId) {
            const cards = Array.from(document.querySelectorAll('.asset-card'))
                .filter(c => c.style.display !== 'none').map(c => c.dataset.id);
            const a = cards.indexOf(this.lastSelectedId);
            const b = cards.indexOf(id);
            if (a !== -1 && b !== -1) {
                cards.slice(Math.min(a,b), Math.max(a,b)+1).forEach(x => this.selectedIds.add(x));
            }
        } else {
            this.selectedIds.has(id) ? this.selectedIds.delete(id) : this.selectedIds.add(id);
        }
        this.lastSelectedId = id;
        this.focusId = id;
        this.updateSelectionUI();
    }

    updateSelectionUI() {
        document.querySelectorAll('.asset-card').forEach(card => {
            const sel = this.selectedIds.has(card.dataset.id);
            card.classList.toggle('selected', sel);
        });
        const sc = document.getElementById('sel-count');
        if (sc) sc.innerText = this.selectedIds.size;
        document.getElementById('selection-toolbar')?.classList.toggle('active', this.selectedIds.size > 0);
    }

    clearSelection() {
        this.selectedIds.clear();
        this.updateSelectionUI();
    }

    openLightbox(id) {
        const lb = document.getElementById('lb');
        const img = document.getElementById('lb-img');
        if (!lb || !img) return;

        img.src = `/preview-hi/${encodeURIComponent(id)}.jpg`;
        lb.classList.remove('hidden');
        
        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                lb.classList.remove('opacity-0');
            });
        });

        this.focusId = id;
        this.updateStats();
    }

    closeLightbox() {
        const lb = document.getElementById('lb');
        if (!lb) return;

        lb.classList.add('opacity-0');
        setTimeout(() => {
            lb.classList.add('hidden');
            const img = document.getElementById('lb-img');
            if (img) img.src = ''; 
        }, 200);
    }

    navigateLightbox(delta) {
        const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none');
        const ids = cards.map(c => c.dataset.id);
        const i = ids.indexOf(this.focusId);
        const next = ids[i + delta];
        if (!next) return;
        this.focusId = next;
        const img = document.getElementById('lb-img');
        if (img) img.src = `/preview-hi/${encodeURIComponent(next)}.jpg`;
        this.updateStats();
    }

    jumpNextUnrated() {
        const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none');
        for (const c of cards) {
            const raw = decodeURIComponent(c.dataset.rating || '').toLowerCase().trim();
            if (!raw || raw === '0') {
                c.scrollIntoView({ behavior:'smooth', block:'center' });
                this.focusId = c.dataset.id;
                this.toast('NEXT UNRATED');
                return;
            }
        }
        this.toast('NO UNRATED');
    }

    scrollToTop() {
        const main = document.querySelector('main');
        main ? main.scrollTo({ top:0, behavior:'smooth' }) : window.scrollTo({ top:0, behavior:'smooth' });
    }

    scrollToBottom() {
        const main = document.querySelector('main');
        main ? main.scrollTo({ top:main.scrollHeight, behavior:'smooth' }) : window.scrollTo({ top:document.body.scrollHeight, behavior:'smooth' });
    }

    addToBucket(id) {
        if (!id) return;
        this.bucket.add(id);
        this.renderBucket();
    }

    removeFromBucket(id) {
        this.bucket.delete(id);
        this.renderBucket();
    }

    renderBucket() {
        const list = document.getElementById('bucket-list');
        if (!list) return;
        list.innerHTML = '';
        this.bucket.forEach(id => {
            const row = document.createElement('div');
            row.className = 'bucket-item flex items-center gap-3 p-2 hover:bg-white/5 cursor-pointer rounded-lg';
            row.innerHTML = `<img src="/preview/${encodeURIComponent(id)}.jpg" class="w-10 h-10 rounded-md object-cover opacity-80"><div class="text-[10px] truncate flex-1 font-mono opacity-70">${id}</div>`;
            row.onclick = () => this.openLightbox(id);
            list.appendChild(row);
        });
        const count = document.getElementById('bucket-count');
        if (count) count.innerText = this.bucket.size;
        document.getElementById('bucket-hud')?.classList.toggle('has-items', this.bucket.size > 0);
    }

    syncState() {
        fetch('/api/status').then(r=>r.json()).then(s=>{
            const bar = document.getElementById('whiskers-bar');
            if (bar) {
                bar.innerText = s.whiskers_text || 'NOMINAL';
                bar.style.color = s.whiskers_color || 'var(--accent)';
            }
            const prog = document.getElementById('job-prog');
            if (prog) {
                prog.style.width = (s.job_progress || 0) + '%';
            }
            setTimeout(()=>this.syncState(), 30000);
        }).catch(()=>setTimeout(()=>this.syncState(), 40000));
    }

    updateStats() {
        const visible = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none').length;
        const visEl = document.getElementById('stat-visible');
        if (visEl) visEl.innerText = visible;

        const lb = document.getElementById('lb');
        if (lb && !lb.classList.contains('hidden')) {
            const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none');
            const idx = cards.findIndex(c => c.dataset.id === this.focusId);
            document.getElementById('lb-current-idx').innerText = idx + 1;
            document.getElementById('lb-total-count').innerText = cards.length;
        }
    }

    toast(msg) {
        const portal = document.getElementById('toast-portal');
        if (!portal) return;
        const t = document.createElement('div');
        t.className = "bg-panel border border-accent/40 text-accent px-6 py-4 rounded-2xl shadow-2xl text-[11px] font-bold mb-2";
        t.innerText = String(msg||'');
        portal.appendChild(t);
        setTimeout(()=>{ t.style.opacity='0'; setTimeout(()=>t.remove(), 500); }, 4000);
    }
}

window.App = new CatalogueApp();