old _server

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

import os
import json
import urllib.parse
import mimetypes
import re
import threading
import shutil
import subprocess
import time
import sys
from pathlib import Path
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from concurrent.futures import ThreadPoolExecutor
from novaweb_utils import STATE, JOB_MANAGER, BASE_DIR, VALID_EXTS

# ============================================================
# 5. CORE ENGINE & UI GENERATION
# ============================================================

def get_themes():
    """Parses themes.css for CSS variable blocks to populate dropdown."""
    theme_file = os.path.join(BASE_DIR, "themes.css")
    themes = ["default"]
    if not os.path.exists(theme_file):
        return themes
    try:
        with open(theme_file, "r", encoding="utf-8") as f:
            content = f.read()
            matches = re.findall(r"body\.theme-([a-zA-Z0-9_-]+)", content)
            for m in matches:
                if m not in themes:
                    themes.append(m)
    except Exception:
        pass
    return sorted(themes)

class CatalogueEngine:
    """The heavy-lifting backend for scanning, previews, and ratings."""
    def __init__(self, path):
        self.src_root = Path(path).resolve()
        
        # Guarded folders allowed for recursive scanning and organization
        self.ALLOWED_FOLDERS = {
            "Pass", "Fix", "Skip", "Reshoot", "Best", "Other",
            "Red", "Orange", "Yellow", "Green", "Blue", "Violet"
        }
        
        self.qc_dir = self.src_root / "QC"
        self.preview_root = self.qc_dir / "Previews"
        self.preview_hi_root = self.qc_dir / "Previews_HI"

        try:
            self.qc_dir.mkdir(exist_ok=True)
            self.preview_root.mkdir(exist_ok=True)
            self.preview_hi_root.mkdir(exist_ok=True)
        except Exception as e:
            print(f"CRITICAL: Failed to initialize volume directories. {e}")
            sys.exit(1)

        self.ratings_file = self.qc_dir / "ratings.json"
        self.ratings = {}
        if self.ratings_file.exists():
            try:
                self.ratings = json.loads(self.ratings_file.read_text())
            except Exception:
                pass

        STATE.update("theme_list", get_themes())
        STATE.update("focus_mode", False) # Authoritative server flag
        self.files = [] # Will now store relative paths
        self.refresh_scan()
        
        # Track if the first full terminal output has occurred
        self.initial_scan_complete = False 
        
        # ---------------------------------------------------------
        # BACKGROUND TRACKER STARTUP
        # This is the "end of initialization" mentioned. It kicks 
        # off the daemon thread that watches for file edits.
        # ---------------------------------------------------------
        threading.Thread(target=self.watch_for_changes, daemon=True).start()

    def watch_for_changes(self):
        """
        Background heartbeat that periodically triggers a smart delta-scan.
        It checks for modification timestamp drifts every 30 seconds.
        """
        print("🕵️  Background Tracker: Monitoring for asset modifications...")
        while True:
            try:
                # Sleep interval between checks to keep I/O overhead low
                time.sleep(30) 
                
                # We trigger generate_previews() without arguments.
                # Because of the new timestamp logic inside process_asset,
                # it will only regenerate what has actually changed.
                if self.files:
                    self.generate_previews()
            except Exception as e:
                print(f"⚠️  Tracker Error: {e}")
                time.sleep(60)

    def refresh_scan(self):
        """
        Guarded Multi-Depth Scan: 
        Checks root, whitelisted subfolders, and Status/Label nested hierarchies.
        """
        if not self.src_root.exists(): return
        
        found_paths = []
        valid_exts_set = set(VALID_EXTS)
        
        # 1. Scan the Root Directory
        try:
            for entry in os.scandir(self.src_root):
                if entry.is_file() and not entry.name.startswith("."):
                    if os.path.splitext(entry.name)[1].lower() in valid_exts_set:
                        found_paths.append(entry.name)
        except Exception: pass
        
        # 2. Scan Whitelisted Folders (Guarded recursion to 2nd level)
        for folder_name in self.ALLOWED_FOLDERS:
            sub_path = self.src_root / folder_name
            if sub_path.exists() and sub_path.is_dir():
                try:
                    for entry in os.scandir(sub_path):
                        # Add files found in the Status or Label folder (e.g., "Pass/img.jpg")
                        if entry.is_file() and not entry.name.startswith("."):
                            if os.path.splitext(entry.name)[1].lower() in valid_exts_set:
                                found_paths.append(f"{folder_name}/{entry.name}")
                        
                        # Check one level deeper for Color Labels inside Status folders
                        if entry.is_dir() and entry.name in self.ALLOWED_FOLDERS:
                            nested_path = sub_path / entry.name
                            for nest_entry in os.scandir(nested_path):
                                if nest_entry.is_file() and not nest_entry.name.startswith("."):
                                    if os.path.splitext(nest_entry.name)[1].lower() in valid_exts_set:
                                        found_paths.append(f"{folder_name}/{entry.name}/{nest_entry.name}")
                except Exception: pass

        self.files = sorted(found_paths)
        STATE.update("queue_length", len(self.files))
        threading.Thread(target=self.generate_previews, daemon=True).start()

    def organize_files(self):
        """
        Hierarchical Sub-Sorting: Moves files into Status/Label subfolders.
        Hierarchy: src_root / [Status] / [Label] / filename.
        """
        moved_count = 0
        asset_ratings = STATE.data.get("ratings", {})
        asset_labels = STATE.data.get("labels", {})
        
        # Whitelist mapping
        valid_statuses = {"pass", "fix", "skip", "reshoot", "best", "other"}
        valid_labels = {"red", "orange", "yellow", "green", "blue", "violet"}

        current_files = list(self.files)

        for rel_path in current_files:
            filename = os.path.basename(rel_path)
            current_full_path = self.src_root / rel_path
            
            status = asset_ratings.get(filename, "0").lower()
            label = asset_labels.get(filename, "none").lower()
            
            path_parts = []
            
            # Tier 1: Status
            if status in valid_statuses:
                path_parts.append(status.capitalize())
            
            # Tier 2: Color Label (Nested under Status if it exists)
            if label in valid_labels:
                path_parts.append(label.capitalize())

            # Construct target path
            if path_parts:
                target_dir = self.src_root.joinpath(*path_parts)
                target_dir.mkdir(parents=True, exist_ok=True)
                target_full_path = target_dir / filename
            else:
                # Return to root if no status/label
                target_full_path = self.src_root / filename

            # Physical relocation
            if current_full_path != target_full_path:
                try:
                    if not target_full_path.exists(): # Prevent collision
                        shutil.move(str(current_full_path), str(target_full_path))
                        moved_count += 1
                except Exception as e:
                    STATE.notify(f"❌ Move Error: {filename} -> {e}")

        STATE.notify(f"📂 Organization Complete: {moved_count} assets relocated.")
        self.refresh_scan()
        return True

    def rebuild_previews(self):
        try:
            if self.preview_root.exists():
                for p in self.preview_root.iterdir():
                    if p.is_file(): p.unlink()
            if self.preview_hi_root.exists():
                for p in self.preview_hi_root.iterdir():
                    if p.is_file(): p.unlink()
        except Exception: pass
        threading.Thread(target=self.generate_previews, daemon=True).start()

    def generate_previews(self, force_ids=None):
        if not shutil.which("magick"): 
            print("⚠️  ImageMagick ('magick') not found. Previews will not be generated.")
            return
            
        targets = [f for f in self.files if os.path.basename(f) in force_ids] if force_ids else self.files
        total = len(targets)
        processed = [0] 
        new_count = [0]
        cached_count = [0]
        lock = threading.Lock()
        
        def process_asset(f_path):
            filename_only = os.path.basename(f_path)
            src = self.src_root / f_path
            dst_lo = self.preview_root / (filename_only + ".jpg")
            dst_hi = self.preview_hi_root / (filename_only + ".jpg")
            
            src_mtime = src.stat().st_mtime if src.exists() else 0
            dst_lo_mtime = dst_lo.stat().st_mtime if dst_lo.exists() else 0
            dst_hi_mtime = dst_hi.stat().st_mtime if dst_hi.exists() else 0

            outdated_lo = (src_mtime > dst_lo_mtime) or (dst_lo.stat().st_size == 0 if dst_lo.exists() else True)
            outdated_hi = (src_mtime > dst_hi_mtime) or (dst_hi.stat().st_size == 0 if dst_hi.exists() else True)

            needs_lo = (not dst_lo.exists()) or outdated_lo
            needs_hi = (not dst_hi.exists()) or outdated_hi

            is_new = False
            if force_ids or needs_lo or needs_hi:
                change_type = "updated" if (dst_lo.exists() and outdated_lo) else "new"
                print(f"  [{change_type}]    {filename_only}")
                is_new = True
                
                if force_ids or needs_lo:
                    subprocess.run(["magick", f"{str(src)}[0]", "-resize", "450x450>", "-quality", "70", "-strip", str(dst_lo)], capture_output=True)
                if force_ids or needs_hi:
                    subprocess.run(["magick", f"{str(src)}[0]", "-resize", "1600x1600>", "-quality", "85", "-strip", str(dst_hi)], capture_output=True)
            else:
                # Only print cached files if this is the first time the engine is running
                if not self.initial_scan_complete:
                    print(f"  [cached]   {filename_only}")
            
            with lock:
                processed[0] += 1
                if is_new:
                    new_count[0] += 1
                else:
                    cached_count[0] += 1
                STATE.update("queue_length", max(0, total - processed[0]))

        with ThreadPoolExecutor(max_workers=4) as executor:
            executor.map(process_asset, targets)

        if new_count[0] > 0:
            print(f"✅ Preview Delta Sync: {new_count[0]} updated, {cached_count[0]} cached, {total} total.")
        
        # Mark the initial run as done so future background syncs remain quiet
        self.initial_scan_complete = True

    def export_qc_csv(self):
        try:
            csv_path = self.qc_dir / "qc_report.csv"
            asset_labels = STATE.data.get("labels", {})
            priority = {"fix": 0, "reshoot": 1, "skip": 2, "other": 3, "pass": 4, "best": 5}
            
            report_data = []
            for fname, status in self.ratings.items():
                p_score = priority.get(status.lower(), 99)
                label = asset_labels.get(fname, "none")
                report_data.append((p_score, status.upper(), fname, label))
            
            report_data.sort(key=lambda x: (x[0], x[2]))

            with open(csv_path, "w", encoding="utf-8") as f:
                f.write("STATUS,FILENAME,LABEL\n")
                for _, status, fname, label in report_data:
                    f.write(f"{status},{fname},{label}\n")
            
            STATE.notify(f"📊 Report generated: {len(report_data)} items categorized.")
            return True
        except Exception as e:
            STATE.notify(f"❌ Export Failed: {str(e)}")
            return False

    def update_rating(self, asset_id, rating):
        if "ratings" not in STATE.data:
            STATE.data["ratings"] = {}

        if str(rating) in ["0", "clear"]:
            self.ratings.pop(asset_id, None)
            STATE.data["ratings"].pop(asset_id, None)
        else:
            self.ratings[asset_id] = str(rating)
            STATE.data["ratings"][asset_id] = str(rating)
            
        STATE.save()

    def update_label(self, asset_id, label):
        if "labels" not in STATE.data:
            STATE.data["labels"] = {}
        
        if not label or label == "none":
            STATE.data["labels"].pop(asset_id, None)
        else:
            STATE.data["labels"][asset_id] = label
        STATE.save()

    def open_in_explorer(self, asset_id):
        # Needs to search for which folder it is currently in
        target_path = None
        for f in self.files:
            if os.path.basename(f) == asset_id:
                target_path = self.src_root / f
                break
        
        if not target_path or not target_path.exists():
            return False
        try:
            if sys.platform == 'win32':
                subprocess.run(['explorer', '/select,', str(target_path)])
            elif sys.platform == 'darwin':
                subprocess.run(['open', '-R', str(target_path)])
            else:
                subprocess.run(['xdg-open', str(target_path.parent)])
            return True
        except Exception:
            return False

    def dashboard(self):
        grid_items = []
        for f_path in self.files:
            filename = os.path.basename(f_path)
            full_path = self.src_root / f_path
            is_ghost = not full_path.exists()
            
            preview_ready = (self.preview_root / (filename + ".jpg")).exists()
            
            raw_r = str(self.ratings.get(filename, STATE.data.get("ratings", {}).get(filename, "0"))).strip()
            label = STATE.data.get("labels", {}).get(filename, "none")
            actions_run = STATE.data.get("actions_run", {})
            act_label = actions_run.get(filename)
            action_tag = f"<div class='absolute bottom-2 right-2 bg-accent/80 text-black text-[8px] font-black px-1.5 py-0.5 rounded-md z-20'>{act_label}</div>" if act_label else ""
            
            r_status = raw_r.lower()
            if r_status in ["pass", "1"]: r_status = "pass"
            elif r_status in ["fix", "2"]: r_status = "fix"
            elif r_status in ["skip", "3"]: r_status = "skip"
            elif r_status in ["reshoot", "4"]: r_status = "reshoot"
            elif r_status in ["best", "5"]: r_status = "best"
            elif r_status in ["other", "6"]: r_status = "other"

            ghost_style = "opacity: 0.35; filter: grayscale(1);" if is_ghost else ""
            ghost_tag = f"<div class='absolute top-2 left-12 bg-red-600 text-white text-[8px] px-1 rounded z-50 animate-pulse'>GHOST</div>" if is_ghost else ""
            state_cls = f"state-{r_status}" if r_status not in ["0", ""] else ""
            
            if preview_ready:
                media_content = f'<img src="/preview/{urllib.parse.quote(filename)}.jpg" class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-110" loading="lazy">'
            else:
                media_content = f'<div class="w-full h-full flex items-center justify-center text-[10px] font-black tracking-[0.2em] text-[var(--accent)] opacity-40 uppercase animate-pulse">Generating...</div>'

            badge_html = ""
            if r_status in ["pass", "fix", "skip", "reshoot", "best", "other"]:
                badge_html = f'<div class="absolute top-2 right-2 badge badge-{r_status}">{r_status.upper()}</div>'
            
            stars = ("★" * int(raw_r)) if (raw_r.isdigit() and int(raw_r) in range(1, 10)) else ""
            label_tag = f'<div class="label-dot label-{label}"></div>' if label != "none" else ""

            grid_items.append(f"""
            <div class="asset-card group relative bg-white/5 backdrop-blur-sm rounded-xl border-2 border-white/10 overflow-hidden transition-all duration-300 select-none shadow-md {state_cls}"
                 data-id="{filename}" data-rating="{r_status}" data-label="{label}" style="{ghost_style}">
                {ghost_tag}
                <div class="aspect-square bg-black/10 relative overflow-hidden preview-hitbox" onclick="App.openLightbox('{filename}', event)">
                    {media_content}
                    {badge_html}
                    {label_tag}
                    {action_tag}
                    <button class="select-hitbox absolute top-2 left-2 w-8 h-8 rounded-xl border border-white/30 bg-black/55 backdrop-blur-md flex items-center justify-center shadow-xl" onclick="App.handleCardClick('{filename}', event)">
                        <span class="text-white/70 text-[14px] leading-none"></span>
                    </button> <button class="absolute top-2 left-12 w-8 h-8 rounded-xl border border-white/30 bg-black/55 backdrop-blur-md flex items-center justify-center shadow-xl hover:bg-accent hover:text-black transition-colors" 
        title="Open in Explorer" 
        onclick="App.locateFile('{filename}', event)">
    <span class="text-[12px]">📂</span>
</button>
                </div>
                <div class="p-3 bg-white/10 backdrop-blur-md border-t border-white/10 flex justify-between items-center cursor-pointer" onclick="App.handleCardClick('{filename}', event)">
                    <div class="truncate text-[11px] font-mono opacity-90 tracking-tight" title="{filename}">{filename}</div>
                    <div class="text-accent text-[10px] drop-shadow-md">{stars}</div>
                </div>
                <div class="selection-overlay absolute inset-0 border-2 border-accent opacity-0 transition-all pointer-events-none rounded-xl"></div>
            </div>
            """)

        current_theme = STATE.get("theme")
        theme_options = "\n".join([f'<option value="{t}" {{"selected" if t==current_theme else ""}}>{t.upper()}</option>' for t in (STATE.get("theme_list") or ["default"])])

        html = f"""
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>NovaWeb Mission Control</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link rel="stylesheet" href="themes.css">
    <link rel="stylesheet" href="main.css">
    <link rel="icon" type="image/svg+xml" href="favicon.svg">
    <script src="app_core.js"></script>
    <script src="fx_core.js"></script>
    <script src="fx_library.js"></script>
    <style>
        @font-face {{ font-family: 'NotoSansEgyptian'; src: url('NotoSansEgyptianHieroglyphs-Regular.ttf') format('truetype'); }}
        .font-egypt {{ font-family: 'NotoSansEgyptian', sans-serif; }}
        header {{
            border-bottom: 1px solid var(--accent) !important;
        }}
        #grid-container {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--grid-min, 210px), 1fr)); gap: 1.5rem; padding-bottom: 12rem; }}
        header {{ display: grid; grid-template-columns: minmax(208px, 320px) minmax(260px, 1fr) minmax(320px, 520px); align-items: center; padding: 0 1.5rem; gap: 1rem; min-width: 0; position: relative; }}
        #progress-hairline {{ position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: rgba(255,255,255,0.05); z-index: 50; }}
        #job-prog {{ height: 100%; background: var(--accent); width: 0%; transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 0 10px var(--accent); }}
        #whiskers-wrap {{
    width: 100%;
    max-width: 450px;
    height: 15px; /* Hard limit */
    overflow: hidden;
    position: relative;
    display: flex;
    align-items: center; /* Vertical center */
    mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
    border-left: 1px solid rgba(255,255,255,0.1);
}}
#whiskers-bar {{
            display: inline-block;
            white-space: nowrap;
            line-height: 1;
            font-size: 10px;
            font-weight: 700;
            letter-spacing: 0.05em;
            padding: 0;
            margin: 0;
            will-change: transform;
            /* STOP THE JUMP: Force the browser to ignore text-length changes for layout */
            flex-shrink: 0;
            min-width: max-content;
        }}

        @keyframes whiskers-scroll {{
            /* Start completely outside the 450px container */
            0% {{ transform: translateX(450px); }}
            /* End exactly when the string has cleared the left edge */
            100% {{ transform: translateX(-100%); }}
        }}
        .sidebar-btn {{ display: flex; align-items: center; gap: 14px; padding: 10px 18px; border-radius: 0; width: 100%; text-align: left; background: transparent; border: none; transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; cursor: pointer; }}
        .sidebar-btn:hover {{ transform: translateY(-3px); color: var(--accent); text-shadow: 0 0 5px currentColor; }}
        .sidebar-btn .icon {{ font-size: 1.25rem; opacity: 0.72; transition: opacity 0.18s ease; }}
        .sidebar-btn:hover .icon {{ opacity: 1; text-shadow: 0 0 5px currentColor; }}
        .sidebar-btn .label {{ font-size: 18px; font-weight: 700; letter-spacing: 0.01em; }}
        
        .grid-slider {{ -webkit-appearance: none; width: 100%; height: 4px; background: rgba(255,255,255,0.18); border-radius: 999px; outline: none; }}
        .grid-slider::-webkit-slider-thumb {{ -webkit-appearance: none; width: 10px; height: 10px; background: var(--accent); border-radius: 50%; cursor: pointer; box-shadow: 0 0 10px rgba(0,0,0,0.6), 0 0 12px var(--accent); transition: transform 0.12s ease; }}
        .grid-slider::-moz-range-thumb {{ width: 10px; height: 10px; border: none; background: var(--accent); border-radius: 50%; cursor: pointer; }}

        .asset-card {{ position: relative !important; isolation: isolate; border: 2px solid color-mix(in srgb, var(--accent) 28%, rgba(255,255,255,0.12)) !important; outline: none !important; }}
        .asset-card.selected {{ outline: 2px solid var(--accent) !important; outline-offset: 2px; box-shadow: 0 0 22px var(--accent) !important; }}
        .asset-card .select-hitbox {{ background: rgba(0,0,0,0.55) !important; border: 1px solid rgba(255,255,255,0.25) !important; z-index: 30; }}
        .asset-card.selected .select-hitbox {{ background: var(--accent) !important; border-color: var(--accent) !important; box-shadow: 0 0 14px var(--accent); }}
        .asset-card.selected .select-hitbox span {{ color: #000 !important; font-weight: 900; }}

        .badge {{ font-size: 10px; font-weight: 800; padding: 3px 7px; border-radius: 8px; letter-spacing: 0.10em; box-shadow: 0 10px 30px rgba(0,0,0,0.35); }}

        #bucket-hud {{ position: fixed; bottom: 2.0rem; left: calc(16rem - 2.5rem); width: 360px; z-index: 90; opacity: 0.40; transition: all 0.22s ease; pointer-events: none; }}
        #bucket-hud.has-items, #bucket-hud:hover {{ opacity: 1; pointer-events: auto; }}
        #bucket-hud.collapsed {{ width: 64px; left: calc(16rem - 3.0rem); }}
        #bucket-hud.collapsed #bucket-hud-shell {{ padding: 10px; border-left-width: 0; border-radius: 18px; display: grid; place-items: center; width: 64px; height: 64px; }}
        #bucket-hud.collapsed #bucket-list, #bucket-hud.collapsed #bucket-actions, #bucket-hud.collapsed #bucket-title span:first-child {{ display: none !important; }}
        #bucket-hud.collapsed #bucket-title {{ display: grid !important; place-items: center !important; padding: 0 !important; pointer-events: none !important; }}
        #bucket-hud.collapsed #bucket-count {{ display: inline-block !important; font-size: 12px; padding: 6px 10px; border-radius: 14px; margin: 0 !important; }}
        #bucket-hud.collapsed #bucket-collapse {{ display: block !important; opacity: 0 !important; position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important; z-index: 50 !important; pointer-events: auto !important; }}
        #bucket-hud-shell {{ background: rgba(60, 60, 60, 0.6); border: 1px solid rgba(255,255,255,0.15); border-left: 4px solid var(--urgent); border-radius: 16px; backdrop-filter: blur(20px); padding: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.50); position: relative; }}
        
        #selection-toolbar {{ position: fixed; bottom: 2.5rem; left: 50%; transform: translateX(-50%) translateY(150px); z-index: 120; background: rgba(15, 15, 15, 0.85); backdrop-filter: blur(32px); border: 1px solid rgba(255,255,255,0.14); border-radius: 24px; padding: 12px 30px; display: flex; align-items: center; gap: 20px; transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); box-shadow: 0 30px 80px rgba(0,0,0,0.85); }}
        #selection-toolbar.active {{ transform: translateX(-50%) translateY(0); }}
        .toolbar-group {{ display: flex; align-items: center; gap: 10px; border-right: 1px solid rgba(255,255,255,0.1); padding-right: 20px; }}
        .toolbar-btn {{ padding: 12px 24px; border-radius: 14px; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.15em; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); transition: all 0.2s ease; cursor: pointer; color: white; }}
        .toolbar-btn:hover {{ background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.25); transform: translateY(-2px); }}
        #lb {{
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 1000;
            display: flex;
            align-items: center;
            justify-content: center;
            background: rgba(0, 0, 0, 0.4);
            backdrop-filter: blur(16px) saturate(180%);
            transition: opacity 0.2s cubic-bezier(0.2, 0, 0, 1);
        }}

        #lb.hidden {{ display: none !important; }}
        #lb.opacity-0 {{ opacity: 0; }}

        #lb-img {{
            max-width: 80vw;
            max-height: 80vh;
            width: auto;
            height: auto;
            object-fit: contain;
            border-radius: 20px;
            background: #000;
            transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
            box-shadow: 
                0 0 0 1px rgba(255, 255, 255, 0.1), 
                0 20px 40px rgba(0, 0, 0, 0.4), 
                0 40px 100px rgba(0, 0, 0, 0.5);
        }}

        #lb.opacity-0 #lb-img {{
            transform: scale(0.96);
        }}

/* FOCUS OVERLAY - Dynamic Glow & Tight Alignment */
        #focus-overlay {{
            position: fixed;
            inset: 0;
            z-index: 50;
            display: none;
            align-items: center;
            justify-content: center;
            pointer-events: none;
            background: transparent !important;
            font-family: monospace !important;
        }}

        #focus-overlay .focus-stack {{
            text-align: center;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            gap: 0.1rem; /* Maximum tightness */
            color: var(--accent);
            pointer-events: auto;
        }}

        #focus-overlay .focus-time {{
            font-size: clamp(6rem, 15vw, 9.5rem);
            font-weight: 900;
            letter-spacing: 0em;
            line-height: 0.85;
            color: var(--accent, var(--accent));
            /* Dynamic Glow matching highlight color */
            filter: drop-shadow(0 0 15px currentColor);
            margin-bottom: 0.2rem;
        }}

        #focus-overlay .focus-date {{
            font-size: 2.4rem;
            opacity: 0.95;
            font-weight: 800;
            letter-spacing: 0.12em;
            text-transform: uppercase;
            margin-top: -0.8rem; /* Pulling up for tight verticality */
            color: var(--accent);
            /* Dynamic Glow matching accent color */
            filter: drop-shadow(0 0 15px currentColor);
        }}

        #focus-overlay .focus-whiskers {{
            font-size: 1rem;
            font-weight: 800;
            opacity: 1;
            margin-top: 1rem;
            color: var(--accent);
            letter-spacing: 0.05em;
            /* Explicit glow matching whiskers text */
            text-shadow: 0 0 5px currentColor;
        }}

        #focus-overlay .focus-task {{
            font-size: 1rem;
            opacity: 0.8;
            margin-top: 1.2rem;
            max-width: 40ch;
            line-height: 1.2;
            font-weight: 600;
            font-style: italic;
            display: block;
            margin-left: auto;
            margin-right: auto;
            color: var(--text-muted, var(--text));
            /* Subtle matching glow for tasks */
            text-shadow: 0 0 3px currentColor;
        }}

    </style>
</head>
<body class="theme-{current_theme} h-screen flex flex-col font-sans select-none overflow-hidden bg-background text-white">
    <header class="h-16 border-b border-white/5 bg-surface z-30 shrink-0">
        <div class="flex items-center space-x-3">
            <div class="w-3.5 h-3.5 rounded-full bg-accent animate-pulse shadow-[0_0_15px_var(--accent)]"></div>
            <h1 class="font-bold text-accent tracking-[0.3em] text-sm uppercase">NOVA<span class="opacity-40 font-normal">WEB</span></h1>
        </div>
        <div id="search-wrap" class="flex justify-center px-2">
            <input type="text" id="search" class="w-[300px] bg-black/40 border border-white/5 rounded-full px-8 py-2 text-xs text-center focus:outline-none transition-all placeholder-white/10" placeholder="SEARCH MISSION LOGS (Press /)" onkeyup="App.filterGrid(this.value)">
        </div>
        <div class="flex items-center justify-end space-x-4">
            <div class="flex items-center space-x-3 bg-black/30 border border-white/5 rounded-full px-5 py-2 backdrop-blur-xl min-w-0">
                <div id="whiskers-wrap">
    <span class="text-[10px] uppercase font-bold tracking-widest" id="whiskers-bar">Initializing...</span>
</div>
            </div>
            <div class="flex items-center gap-2">
                <button onclick="App.enterFocusMode()" class="bg-surface border border-white/10 text-[10px] rounded px-3 py-1.5 uppercase opacity-60 hover:opacity-100 hover:text-accent font-black tracking-tighter transition-all" title="Focus Mode [F]">Focus</button>
                <select onchange="App.changeTheme(this.value)" class="bg-surface border border-white/10 text-[10px] rounded px-2.5 py-1.5 uppercase opacity-60 hover:opacity-100 cursor-pointer">{theme_options}</select>
            </div>
        </div>
        
        <div id="progress-hairline"><div id="job-prog"></div></div>
    </header>

    <div class="flex flex-1 overflow-hidden relative z-10">
        <aside class="w-52 bg-panel/40 border-r border-white/5 flex flex-col shrink-0 overflow-y-auto backdrop-blur-xl custom-scrollbar">
            <div class="p-4 space-y-6">
                <div class="px-4 py-3 mb-6 bg-black/30 rounded-2xl border border-white/5 space-y-2 shadow-inner">
                    <div class="flex justify-between items-center">
                        <span class="text-[9px] font-black uppercase tracking-widest opacity-30">Visible</span>
                        <span id="stat-visible" class="text-accent font-mono text-xs">0</span>
                    </div>
                    <div class="flex justify-between items-center">
                        <span class="text-[9px] font-black uppercase tracking-widest opacity-30">Unrated</span>
                        <span id="stat-unrated" class="text-white font-mono text-xs">0</span>
                    </div>
                </div>
                <div class="space-y-4 px-2">
                    <div class="flex justify-between items-center opacity-30 text-[9px] font-bold uppercase tracking-widest"><span>Thumbnail Size</span><span id="zoom-val">210px</span></div>
                    <input id="grid-range" type="range" min="160" max="650" value="210" class="grid-slider" oninput="App.setGridMinPx(this.value)">
                </div>
                <div class="flex gap-2">
                     <button onclick="App.jumpNextUnrated()" class="flex-1 py-4 border border-white/10 bg-white/5 rounded-2xl hover:bg-accent hover:text-black text-[10px] font-black tracking-widest transition-all">NEXT</button>
                     <button onclick="App.scrollToTop()" class="flex-1 py-4 border border-white/10 bg-white/5 rounded-2xl hover:bg-accent hover:text-black text-[10px] font-black tracking-widest transition-all">TOP</button>
                     <button onclick="App.scrollToBottom()" class="flex-1 py-4 border border-white/10 bg-white/5 rounded-2xl hover:bg-urgent hover:text-white text-[10px] font-black tracking-widest transition-all">BOTTOM</button>
                </div>
                
                <button onclick="App.filterByLabel('all')" class="sidebar-btn"><span class="label">Show All Assets</span></button>

                <div class="space-y-4">
    <h3 class="text-[9px] font-bold uppercase tracking-[0.5em] opacity-20 mb-3 ml-2">Labels</h3>
    <div class="flex items-center gap-4 ml-0 px-1">
        <button onclick="App.filterByLabel('red')" class="hover:scale-125 transition-transform"><span style="color:#ef4444; font-size:14px;"></span></button>
        <button onclick="App.filterByLabel('orange')" class="hover:scale-125 transition-transform"><span style="color:#f97316; font-size:14px;"></span></button>
        <button onclick="App.filterByLabel('yellow')" class="hover:scale-125 transition-transform"><span style="color:#eab308; font-size:14px;"></span></button>
        <button onclick="App.filterByLabel('green')" class="hover:scale-125 transition-transform"><span style="color:#22c55e; font-size:14px;"></span></button>
        <button onclick="App.filterByLabel('blue')" class="hover:scale-125 transition-transform"><span style="color:#3b82f6; font-size:14px;"></span></button>
        <button onclick="App.filterByLabel('violet')" class="hover:scale-125 transition-transform"><span style="color:#8b5cf6; font-size:14px;"></span></button>
        <button onclick="App.applyLabel('none')" class="hover:scale-125 transition-all duration-200 group/clear" title="Clear Label">
            <span class="text-white/30 group-hover/clear:text-accent font-black text-lg leading-none">×</span>
        </button>
    </div>
</div>

                <div class="space-y-4">
                    <h3 class="text-[9px] font-bold uppercase tracking-[0.5em] opacity-20 mb-3 ml-2">Review</h3>
                    <button onclick="App.applyQCFilter('unrated')" class="sidebar-btn group"><span class="icon">🖼️️</span> <span class="label">Unrated</span></button>
                    <button onclick="App.applyQCFilter('fix')" class="sidebar-btn group"><span class="icon">🚨</span> <span class="label">Fix</span></button>
                    <button onclick="App.applyQCFilter('pass')" class="sidebar-btn group"><span class="icon">🎉</span> <span class="label">Passed</span></button>
                    <button onclick="App.applyQCFilter('skip')" class="sidebar-btn group"><span class="icon"></span> <span class="label">Skipped</span></button>
                    <button onclick="App.applyQCFilter('best')" class="sidebar-btn group"><span class="icon">💎</span> <span class="label">Best</span></button>
                    <button onclick="App.applyQCFilter('reshoot')" class="sidebar-btn group"><span class="icon">📸</span> <span class="label">Reshoot</span></button>
                    <button onclick="App.applyQCFilter('other')" class="sidebar-btn group"><span class="icon">🧪</span> <span class="label">Other</span></button>
                    <button onclick="App.applyQCFilter('clear')" class="sidebar-btn group"><span class="icon">🚿</span> <span class="label">Clear Filters</span></button>
                </div>
                <div class="h-px bg-white/5 mx-2"></div>
                <div class="space-y-4">
                    <h3 class="text-[9px] font-bold uppercase tracking-[0.5em] opacity-20 mb-3 ml-2">Actions</h3>
                    <button onclick="App.runJob('autocrop')" class="sidebar-btn group"><span class="icon">✂️</span> <span class="label">Autocrop</span></button>
                    <button onclick="App.runJob('adjust')" class="sidebar-btn group"><span class="icon">🪄</span> <span class="label">Auto-Adjust</span></button>
                    <button onclick="App.runJob('delete_layers')" class="sidebar-btn group"><span class="icon">🥞</span> <span class="label">Delete Layers</span></button>
                    <button onclick="App.runJob('export')" class="sidebar-btn group"><span class="icon">📦</span> <span class="label">Export Final</span></button>
                </div>
                <div class="h-px bg-white/5 mx-2"></div>
                <div class="space-y-4">
                    <h3 class="text-[9px] font-bold uppercase tracking-[0.5em] opacity-20 mb-3 ml-2">Housekeeping</h3>
                    <button onclick="App.rebuildPreviews()" class="sidebar-btn group"><span class="icon">🧱</span> <span class="label">Rebuild Previews</span></button>
                    <button onclick="App.rescanFolder()" class="sidebar-btn group"><span class="icon">🔁</span> <span class="label">Rescan Folder</span></button>
                    <button onclick="App.runJob('organize')" class="sidebar-btn group"><span class="icon">📂</span> <span class="label">Organize Files</span></button>
                    <button onclick="App.clearFlags()" class="sidebar-btn group"><span class="icon">🧼</span> <span class="label">Remove Ratings</span></button>
                    <button onclick="App.exportQCCSV()" class="sidebar-btn group"><span class="icon">📊</span> <span class="label">Generate Report</span></button>
                </div>
                <div class="h-px bg-white/5 mx-2"></div>
                <div class="space-y-4">
                    <h3 class="text-[9px] font-bold uppercase tracking-[0.5em] opacity-20 mb-3 ml-2">Grid Size</h3>
                    <button onclick="App.setGridPreset('small')" class="sidebar-btn group"><span class="icon">🥉</span> <span class="label">Small</span></button>
                    <button onclick="App.setGridPreset('medium')" class="sidebar-btn group"><span class="icon">🥈</span> <span class="label">Medium</span></button>
                    <button onclick="App.setGridPreset('large')" class="sidebar-btn group"><span class="icon">🥇</span> <span class="label">Large</span></button>
                </div>
            </div>
            <div class="mt-auto p-8 border-t border-white/5 bg-black/40 text-[11px] font-mono text-accent text-center truncate shadow-inner">{self.src_root.name}</div>
        </aside>
        <main class="flex-1 overflow-y-auto p-12 bg-black/5 custom-scrollbar"><div id="grid-container">{"".join(grid_items)}</div></main>
    </div>

    <div id="selection-toolbar">
        <div class="toolbar-group">
            <span class="text-[10px] font-black text-accent mr-2"><span id="sel-count">0</span> SELECTED</span>
        </div>
        <div class="toolbar-group">
            <button class="toolbar-btn text-green-400" onclick="App.batchRating('pass')">Pass [1]</button>
            <button class="toolbar-btn text-red-400" onclick="App.batchRating('fix')">Fix [2]</button>
            <button class="toolbar-btn text-yellow-400" onclick="App.batchRating('skip')">Skip [3]</button>
            <button class="toolbar-btn text-orange-400" onclick="App.batchRating('reshoot')">Reshoot [4]</button>
            <button class="toolbar-btn text-blue-400" onclick="App.batchRating('best')">Best [5]</button>
            <button class="toolbar-btn text-purple-400" onclick="App.batchRating('other')">Other [6]</button>
            <button class="toolbar-btn text-white/50 border-white/20" onclick="App.clearFlags()">Clear [0]</button>
        </div>
        <div class="toolbar-group">
            <select class="bg-black/40 border border-white/10 text-[10px] rounded-xl px-4 py-2 font-bold uppercase outline-none text-white" onchange="App.applyLabel(this.value); this.value='none'">
                <option value="none">Label</option>
                <option value="red">Red</option>
                <option value="orange">Orange</option>
                <option value="yellow">Yellow</option>
                <option value="green">Green</option>
                <option value="blue">Blue</option>
                <option value="violet">Violet</option>
            </select>
        </div>
        <button class="text-[9px] opacity-40 hover:opacity-100 font-bold" onclick="App.clearSelection()">CANCEL</button>
    </div>

    <div id="bucket-hud">
        <div id="bucket-hud-shell">
            <button id="bucket-collapse" onclick="App.toggleBucket()" title="Collapse"></button>
            <div id="bucket-title" class="flex justify-between items-center mb-2 pr-8">
                <span class="text-[9px] font-black uppercase tracking-widest text-urgent">FIX BUCKET</span>
                <span id="bucket-count" class="text-[10px] font-bold px-2 py-0.5 bg-urgent text-white rounded-full">0</span>
            </div>
            <div id="bucket-list" class="max-h-80 overflow-y-auto space-y-1 custom-scrollbar"></div>
            <div id="bucket-actions" class="flex gap-2 mt-3">
                <button onclick="App.copyBucket()" class="flex-1 py-2 text-[8px] font-bold border border-white/20 rounded-lg hover:bg-white/10 transition-colors bg-white/5 uppercase tracking-wider">COPY LIST</button>
            </div>
        </div>
    </div>

    <div id="lb" class="fixed inset-0 z-[120] bg-black/98 hidden flex items-center justify-center opacity-0 transition-opacity duration-300 backdrop-blur-xl" onclick="if(event.target===this) App.closeLb()">
        <div class="absolute top-10 left-1/2 -translate-x-1/2 flex items-center gap-4">
            <div class="h-px w-12 bg-white/10"></div>
            <div class="text-[10px] font-black tracking-[0.4em] text-white/40 uppercase">
                Item <span id="lb-current-idx" class="text-accent">0</span> / <span id="lb-total-count">0</span>
            </div>
            <div class="h-px w-12 bg-white/10"></div>
        </div>
        <img id="lb-img" src="" class="max-h-screen max-w-screen object-contain shadow-2xl transition-transform duration-300 ease-out">
        <div class="absolute bottom-12 bg-black/60 border border-white/10 rounded-full px-12 py-5 flex gap-10 text-[10px] font-bold text-white/80 backdrop-blur-3xl shadow-2xl ring-1 ring-white/5 pointer-events-auto">
            <span class="cursor-pointer hover:text-green-400 transition-colors" onclick="App.rateAndAdvance('pass')">PASS [1]</span>
            <span class="cursor-pointer hover:text-red-400 transition-colors" onclick="App.rateAndAdvance('fix')">FIX [2]</span>
            <span class="cursor-pointer hover:text-yellow-400 transition-colors" onclick="App.rateAndAdvance('skip')">SKIP [3]</span>
            <span class="cursor-pointer hover:text-orange-400 transition-colors" onclick="App.rateAndAdvance('reshoot')">RESHOOT [4]</span>
            <span class="cursor-pointer hover:text-blue-400 transition-colors" onclick="App.rateAndAdvance('best')">BEST [5]</span>
            <span class="cursor-pointer hover:text-gray-400 transition-colors" onclick="App.clearFlags()">CLEAR [0]</span>
            <span class="opacity-20">|</span>
            <span class="cursor-pointer hover:text-white transition-colors" onclick="App.closeLb()">CLOSE [ESC]</span>
        </div>
    </div>

    <div id="focus-overlay">
        <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>
    </div>

    <div id="toast-portal" class="fixed bottom-12 right-12 z-[130] flex flex-col gap-4 pointer-events-none"></div>

   <script>
        const App = {{
            selection: new Set(), focusId: null, lastId: null, qcFilterMode: "clear", seenNotifications: new Set(),
            focusMode: false, focusIdleMinutes: 10,
            focusTimer: null, focusClockTimer: null, focusTaskTimer: null,
            focusTasks: null, focusTaskIndex: 0,
            locateFile(id, e) {{
                if (e) e.stopPropagation();
                if (!id) return;
                fetch('/api/open', {{
                    method: 'POST',
                    body: JSON.stringify({{ id: id }})
                }}).then(r => r.json()).then(d => {{
                    if (d.status === 'ok') this.showToast("REVEALED");
                }});
            }},

            init() {{
                this.setGridMinPx(document.getElementById('grid-range').value);
                this.renderBucket(); 
                this.syncState();
                this.updateStats();
                window.addEventListener('keydown', e => this.handleHotkeys(e));
                if (window.HBC && window.HBC.Engine) window.HBC.Engine.init();

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

                this.initFocusOverlay();
                this.resetFocusTimer();

                const isCollapsed = localStorage.getItem('novaweb_bucket_collapsed') === 'true';
                if (isCollapsed) {{
                    document.getElementById('bucket-hud')?.classList.add('collapsed');
                }}

                // WHISKERS STARTUP - Keeps "Initializing..." and fixes jumping for ALL lines
                const whiskers = document.getElementById('whiskers-bar');
                if (whiskers) {{
                    whiskers.style.animation = 'whiskers-scroll 25s linear infinite';
                    
                    // SWAP GATEKEEPER: This event fires at the end of every scroll lap.
                    // It ensures text swaps ONLY happen when the element is off-screen.
                    whiskers.addEventListener('animationiteration', () => {{
                        if (whiskers.dataset.pendingText) {{
                            whiskers.innerText = whiskers.dataset.pendingText;
                            whiskers.dataset.pendingText = ""; 
                        }}
                    }});
                }}
            }},

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

            toggleFocus() {{ if (this.focusMode) this.exitFocusMode(); else this.enterFocusMode(); }},

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

                document.body.classList.add('focus-mode');
                document.getElementById('focus-overlay').style.display = 'flex';

                this.updateFocusOverlay();

                // seed FIRST task immediately
                this.rotateFocusTask();

                // 1s clock ONLY for time/date/whiskers
                this.focusClockTimer = setInterval(
                    () => this.updateFocusOverlay(),
                    1000
                );

                // minute-aligned task rotation AFTER initial seed
                this.scheduleNextTask();
            }},

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

                document.body.classList.remove('focus-mode');
                document.getElementById('focus-overlay').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
                );
            }},

            scheduleNextTask() {{
                const now = new Date();
                const msUntilNextMinute =
                    (60 - now.getSeconds()) * 1000 - now.getMilliseconds();

                this.focusTaskTimer = setTimeout(() => {{
                    this.rotateFocusTask();
                    this.scheduleNextTask();
                }}, msUntilNextMinute);
            }},

            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) whiskEl.innerText = document.getElementById('whiskers-bar').innerText;
            }},

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

            updateStats() {{
                const allCards = Array.from(document.querySelectorAll('.asset-card'));
                const visibleCards = allCards.filter(c => c.style.display !== 'none');
                const unratedCount = allCards.filter(c => {{
                    const r = decodeURIComponent(c.dataset.rating || '').trim();
                    return !r || r === 'null' || r === 'undefined' || r === '0';
                }}).length;

                const visEl = document.getElementById('stat-visible');
                const unrEl = document.getElementById('stat-unrated');
                if(visEl) visEl.innerText = visibleCards.length;
                if(unrEl) unrEl.innerText = unratedCount;

                const lb = document.getElementById('lb');
                if (lb && !lb.classList.contains('hidden') && this.focusId) {{
                    const idx = visibleCards.findIndex(c => c.dataset.id === this.focusId);
                    document.getElementById('lb-current-idx').innerText = idx + 1;
                    document.getElementById('lb-total-count').innerText = visibleCards.length;
                }}
            }},

            setGridMinPx(v) {{
                document.documentElement.style.setProperty('--grid-min', v + 'px');
                document.getElementById('zoom-val').innerText = v + 'px';
            }},

            setGridPreset(which) {{
                if (which === 'small') this.setGridMinPx(170);
                if (which === 'medium') this.setGridMinPx(210);
                if (which === 'large') this.setGridMinPx(290);
            }},

            applyQCFilter(mode) {{
                this.qcFilterMode = mode;
                document.querySelectorAll('.asset-card').forEach(c => {{
                    const r = decodeURIComponent(c.dataset.rating || '').toLowerCase().trim();
                    const isUnrated = !r || r === 'null' || r === 'undefined' || r === '0';
                    let show = (mode === 'clear') ? true : (mode === 'unrated' ? isUnrated : r === mode);
                    c.style.display = show ? '' : 'none';
                }});
                this.showToast(mode.toUpperCase() + " FILTER APPLIED");
                this.updateStats();
            }},

            filterByLabel(labelName) {{
                document.querySelectorAll('.asset-card').forEach(card => {{
                    if (labelName === 'all') {{
                        card.style.display = '';
                    }} else {{
                        card.style.display = card.dataset.label === labelName ? '' : 'none';
                    }}
                }});
                this.showToast(labelName.toUpperCase() + " LABEL FILTER APPLIED");
                this.updateStats();
            }},

            clearFlags() {{
                if (this.selection.size > 0) {{
                    this.batchRating('0');
                }} else if (this.focusId) {{
                    this.applyRating(this.focusId, '0');
                }}
                this.showToast("FLAGS CLEARED");
            }},

            jumpNextUnrated() {{
                const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none');
                const next = cards.find(c => {{
                    const r = decodeURIComponent(c.dataset.rating || '').trim();
                    return !r || r === '0';
                }});
                if (next) {{ next.scrollIntoView({{ behavior: 'smooth', block: 'center' }}); this.focusId = next.dataset.id; }}
            }},

            scrollToTop() {{ document.querySelector('main').scrollTo({{ top: 0, behavior: 'smooth' }}); }},
            scrollToBottom() {{ const m = document.querySelector('main'); m.scrollTo({{ top: m.scrollHeight, behavior: 'smooth' }}); }},
            
            applyRating(id, status) {{
                if (!id) return;
                fetch('/api/rate', {{ method: 'POST', body: JSON.stringify({{ id, rating: status }}) }}).then(() => {{
                    const card = document.querySelector(`.asset-card[data-id="${{id}}"]`);
                    if (card) {{
                        card.dataset.rating = encodeURIComponent(status);
                        card.className = card.className.replace(/state-\\\\w+/g, '') + ' state-' + status;
                        let b = card.querySelector('.badge');
                        if (!b) {{ b = document.createElement('div'); b.className = 'absolute top-2 right-2 badge'; card.querySelector('.preview-hitbox').appendChild(b); }}
                        b.className = `absolute top-2 right-2 badge badge-${{status}}`; b.innerText = status.toUpperCase();
                    }}
                    this.renderBucket();
                    this.updateStats();
                }});
            }},

            applyLabel(label) {{
                const targets = this.selection.size > 0 ? Array.from(this.selection) : (this.focusId ? [this.focusId] : []);
                if (!targets.length) return;

                targets.forEach(id => {{
                    fetch('/api/label', {{
                        method: 'POST',
                        body: JSON.stringify({{ id: id, label: label }})
                    }}).then(() => {{
                        const card = document.querySelector(`.asset-card[data-id="${{id}}"]`);
                        if (card) {{
                            card.dataset.label = label;

                            const hb = card.querySelector('.preview-hitbox');
                            let dot = card.querySelector('.label-dot');

                            if (label === 'none' || !label) {{
                                if (dot) dot.remove();
                            }} else {{
                                if (!dot) {{
                                    dot = document.createElement('div');
                                    hb.appendChild(dot);
                                }}
                                dot.className = `label-dot label-${{label}}`;
                            }}
                        }}
                    }});
                }});
                
                this.showToast("APPLIED " + label.toUpperCase() + " LABEL");
                this.clearSelection();
            }},

            renderBucket() {{
                const list = document.getElementById('bucket-list');
                const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => {{
                    const r = decodeURIComponent(c.dataset.rating || '');
                    return r === 'fix' || r === 'reshoot';
                }});
                if(document.getElementById('bucket-count')) document.getElementById('bucket-count').innerText = cards.length;
                if(document.getElementById('bucket-hud')) document.getElementById('bucket-hud').classList.toggle('has-items', cards.length > 0);
                if(list) {{
                    list.innerHTML = '';
                    cards.forEach(c => {{
                        const id = c.dataset.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 transition-colors';
                        row.innerHTML = `<img src="/preview/${{encodeURIComponent(id)}}.jpg" class="bucket-thumb-small w-10 h-10 rounded-md object-cover opacity-80"><div class="bucket-text text-[10px] truncate flex-1 font-mono opacity-70">${{id}}</div>`;
                        row.onclick = () => this.openLightbox(id); list.appendChild(row);
                    }});
                }}
            }},

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

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

            clearSelection() {{ this.selection.clear(); this.updateSelectionUI(); }},

            batchRating(status) {{
                this.selection.forEach(id => this.applyRating(id, status));
                this.showToast("BATCH UPDATED: " + status.toUpperCase());
                this.clearSelection();
            }},

            rateAndAdvance(status) {{
                const id = this.focusId; if(!id) return;
                this.applyRating(id, status);
                const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none');
                const ids = cards.map(c => c.dataset.id);
                const nextId = ids[ids.indexOf(id) + 1];
                if (nextId) {{ this.openLightbox(nextId); }} else {{ this.closeLb(); }}
            }},

            openLightbox(id, e) {{
                if(e && (e.metaKey || e.ctrlKey || e.shiftKey)) {{ this.handleCardClick(id,e); return; }}
                this.focusId = id; 
                document.getElementById('lb-img').src = `/preview-hi/${{encodeURIComponent(id)}}.jpg`;
                const lb = document.getElementById('lb'); 
                lb.classList.remove('hidden'); 
                requestAnimationFrame(() => lb.classList.remove('opacity-0'));
                this.updateStats();
            }},

            closeLb() {{ 
                document.getElementById('lb').classList.add('opacity-0'); 
                setTimeout(() => document.getElementById('lb').classList.add('hidden'), 300); 
            }},

            copyBucket() {{
                const txt = Array.from(document.querySelectorAll('.asset-card')).filter(c => {{
                    const r = decodeURIComponent(c.dataset.rating || '');
                    return r === 'fix' || r === 'reshoot';
                }}).map(c => c.dataset.id).join('\\n');
                navigator.clipboard.writeText(txt).then(() => this.showToast("FILENAMES COPIED"));
            }},

            rescanFolder() {{ fetch('/api/rescan').then(() => {{ this.showToast("RESCAN STARTED"); setTimeout(() => location.reload(), 650); }}); }},
            rebuildPreviews() {{ fetch('/api/rebuild_previews').then(() => {{ this.showToast("REBUILDING..."); setTimeout(() => location.reload(), 900); }}); }},
            exportQCCSV() {{ fetch('/api/export_qc_csv').then(r => r.json()).then(d => this.showToast(d.ok ? "EXPORTED" : "FAILED")); }},
            runJob(key) {{ fetch(`/api/run/${{key}}`).then(() => this.showToast("MISSION INITIATED: " + key.toUpperCase())); }},

            syncState() {{
            fetch('/api/status').then(r => r.json()).then(s => {{
                const b = document.getElementById('whiskers-bar');
                if(b) {{ 
                    b.style.color = s.whiskers_color; 
                    
                    // Check if server text differs from what is currently on screen.
                    // If it does, we queue it. This allows current text (including 
                    // "Initializing...") to finish its lap before being replaced.
                    if (b.innerText !== s.whiskers_text) {{
                        b.dataset.pendingText = s.whiskers_text;
                    }}
                }}

                const prog = document.getElementById('job-prog');
                if (prog) prog.style.width = (s.job_progress || 0) + '%';

                (s.notifications || []).forEach(n => {{ 
                    if(!this.seenNotifications.has(n)) {{ 
                        this.seenNotifications.add(n); 
                        this.showToast(n); 
                    }} 
                }});

                // 35s average interval for the sync loop
                setTimeout(() => this.syncState(), 30000);
            }}).catch(() => setTimeout(() => this.syncState(), 40000));
        }},

            filterGrid(q) {{
                const t = q.toLowerCase();
                document.querySelectorAll('.asset-card').forEach(c => c.style.display = c.dataset.id.toLowerCase().includes(t) ? '' : 'none');
                if(this.qcFilterMode!=='clear') this.applyQCFilter(this.qcFilterMode);
                this.updateStats();
            }},

            showToast(msg) {{
                const p = document.getElementById('toast-portal'), t = document.createElement('div');
                t.className = "bg-panel border border-accent/40 text-accent px-6 py-4 rounded-2xl text-[11px] font-bold shadow-2xl ring-1 ring-white/5 pointer-events-auto mb-2";
                t.innerText = msg; p.appendChild(t); setTimeout(() => t.remove(), 4000);
            }},

            handleHotkeys(e) {{
                if(['INPUT','SELECT'].includes(document.activeElement.tagName)) return;
                const k = e.key.toLowerCase();
                const lbOpen = !document.getElementById('lb').classList.contains('hidden');

                if(this.focusMode) {{ this.exitFocusMode(); return; }}
                if(k==='f') {{ e.preventDefault(); this.toggleFocus(); return; }}

                if(k==='/') {{ e.preventDefault(); document.getElementById('search').focus(); }}
                if(k==='escape') {{ this.closeLb(); this.clearSelection(); }}
                if(k===' ') {{ e.preventDefault(); if(this.focusId) this.openLightbox(this.focusId); }}
                
                if (['arrowright', 'arrowleft', 'arrowdown', 'arrowup'].includes(k)) {{
                    if (lbOpen) {{
                        const cards = Array.from(document.querySelectorAll('.asset-card')).filter(c => c.style.display !== 'none');
                        const idx = cards.findIndex(c => c.dataset.id === this.focusId);
                        let next;
                        if (k === 'arrowright' || k === 'arrowdown') next = cards[idx + 1];
                        if (k === 'arrowleft' || k === 'arrowup') next = cards[idx - 1];
                        if (next) this.openLightbox(next.dataset.id);
                    }}
                    return;
                }}

                if(['1','n'].includes(k)) this.focusId && lbOpen ? this.rateAndAdvance('pass') : this.batchRating('pass');
                if(['2','p'].includes(k)) this.focusId && lbOpen ? this.rateAndAdvance('fix') : this.batchRating('fix');
                if(['3','s'].includes(k)) this.focusId && lbOpen ? this.rateAndAdvance('skip') : this.batchRating('skip');
                if(k==='4') this.focusId && lbOpen ? this.rateAndAdvance('reshoot') : this.batchRating('reshoot');
                if(k==='5') this.focusId && lbOpen ? this.rateAndAdvance('best') : this.batchRating('best');
                if(k==='6') this.focusId && lbOpen ? this.rateAndAdvance('other') : this.batchRating('other');

                if(k==='j') this.jumpNextUnrated();
                if(k==='t') this.scrollToTop();
                if(k==='b') this.scrollToBottom();
                if(k==='x') this.runJob('autocrop');
                if(k==='e') this.runJob('export');
                if(k==='d') this.runJob('adjust');
                if(k==='m') this.runJob('organize');
                if((e.metaKey || e.ctrlKey) && k === 'a') {{
                    e.preventDefault();
                    document.querySelectorAll('.asset-card').forEach(c => {{ if (c.style.display !== 'none') this.selection.add(c.dataset.id); }});
                    this.updateSelectionUI();
                }}
            }},
            changeTheme(t) {{ fetch('/api/theme', {{ method: 'POST', body: JSON.stringify({{ theme: t }}) }}).then(() => location.reload()); }}
        }};
        App.init();
    </script>
</body>
</html>
"""
        return html

class NovaWebHandler(BaseHTTPRequestHandler):
    engine = None
    def log_message(self, format, *args): return

    def do_GET(self):
        path_root = self.path.split("?")[0]
        if path_root == "/api/status": return self.send_json(STATE.snapshot())
        if path_root == "/api/focus_tasks":
            p = os.path.join(BASE_DIR, "focus_tasks.json")
            if os.path.exists(p):
                try:
                    self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers()
                    with open(p, "rb") as s: self.wfile.write(s.read()); return
                except (ConnectionResetError, BrokenPipeError): return
            return self.send_json({"today": ["Review Mission Logs"]})
        if path_root.startswith("/api/run/"):
            k = path_root.split("/")[-1]
            if k == "organize":
                self.engine.organize_files() # Trigger Python engine native sorting
            else:
                JOB_MANAGER.run(k, str(self.engine.src_root))
            return self.send_json({"status": "ok"})
        if path_root == "/api/rescan": self.engine.refresh_scan(); return self.send_json({"ok": True})
        if path_root == "/api/rebuild_previews": self.engine.rebuild_previews(); return self.send_json({"ok": True})
        if path_root == "/api/export_qc_csv": return self.send_json({"ok": self.engine.export_qc_csv()})
        if path_root.endswith((".css", ".js", ".ttf", ".svg")): return self.serve_static_asset(path_root.lstrip("/"))
        if path_root.startswith("/preview/"): return self.serve_media_preview(path_root, hi=False)
        if path_root.startswith("/preview-hi/"): return self.serve_media_preview(path_root, hi=True)
        if path_root in ["/", "/index.html"]:
            try:
                self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.end_headers()
                self.wfile.write(self.engine.dashboard().encode("utf-8")); return
            except (ConnectionResetError, BrokenPipeError): return
        self.send_error(404)

    def do_POST(self):
        l = int(self.headers.get("Content-Length", 0))
        d = json.loads(self.rfile.read(l).decode("utf-8")) if l > 0 else {}
        if self.path == "/api/run_action":
            key = d.get("key")
            ids = d.get("ids", [])
            if key == "organize":
                self.engine.organize_files()
            else:
                def fin(affected): self.engine.generate_previews(force_ids=affected)
                JOB_MANAGER.run(key, str(self.engine.src_root), target_ids=ids, on_complete=fin)
            return self.send_json({"status": "ok"})
        if self.path == "/api/rate":
            self.engine.update_rating(d.get("id"), d.get("rating"))
            return self.send_json({"status": "ok"})
        if self.path == "/api/label":
            self.engine.update_label(d.get("id"), d.get("label"))
            return self.send_json({"status": "ok"})
        if self.path == "/api/focus_mode":
            STATE.update("focus_mode", d.get("state", False))
            return self.send_json({"status": "ok"})
        if self.path == "/api/open":
            success = self.engine.open_in_explorer(d.get("id"))
            return self.send_json({"status": "ok" if success else "error"})
        if self.path == "/api/theme":
            STATE.update("theme", d.get("theme"))
            return self.send_json({"status": "ok"})
        self.send_error(404)

    def send_json(self, d):
        try:
            self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers()
            self.wfile.write(json.dumps(d).encode("utf-8"))
        except (ConnectionResetError, BrokenPipeError): pass

    def serve_static_asset(self, f):
        p = os.path.join(BASE_DIR, f)
        if os.path.exists(p):
            try:
                self.send_response(200); self.send_header("Content-Type", mimetypes.guess_type(p)[0] or "text/plain"); self.end_headers()
                with open(p, "rb") as s: self.wfile.write(s.read())
            except (ConnectionResetError, BrokenPipeError): pass
        else: self.send_error(404)

    def serve_media_preview(self, r, hi=False):
        fn = urllib.parse.unquote(r.replace("/preview-hi/" if hi else "/preview/", ""))
        p = (self.engine.preview_hi_root if hi else self.engine.preview_root) / fn
        if p.exists():
            try:
                self.send_response(200); self.send_header("Content-Type", "image/jpeg"); self.end_headers()
                with open(p, "rb") as s: self.wfile.write(s.read())
            except (ConnectionResetError, BrokenPipeError): pass
        else: self.send_error(404)

# ============================================================
# SERVER ENTRY POINT
# ============================================================

def run_server(port=8000, engine=None):
    NovaWebHandler.engine = engine
    # ThreadingHTTPServer is critical for Python 3.14 to handle concurrent asset requests 
    # without triggering BrokenPipeError/ConnectionResetError on slow static loads.
    server = ThreadingHTTPServer(("", port), NovaWebHandler)
    print(f"📡 NovaWeb Server active at http://localhost:{port}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        server.server_close()
        sys.exit(0)