"use client"; import { useEffect, useState, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; import { useTranslation } from "../../lib/i18n/context"; import { Badge } from "./ui/Badge"; import { ProgressBar } from "./ui/ProgressBar"; interface Job { id: string; library_id: string | null; type: string; status: string; current_file: string | null; progress_percent: number | null; processed_files: number | null; total_files: number | null; stats_json: { scanned_files: number; indexed_files: number; errors: number; warnings: number; } | null; } // Icons const JobsIcon = ({ className }: { className?: string }) => ( ); const SpinnerIcon = ({ className }: { className?: string }) => ( ); const ChevronIcon = ({ className }: { className?: string }) => ( ); export function JobsIndicator() { const { t } = useTranslation(); const [activeJobs, setActiveJobs] = useState([]); const [isOpen, setIsOpen] = useState(false); const buttonRef = useRef(null); const popinRef = useRef(null); const [popinStyle, setPopinStyle] = useState({}); useEffect(() => { const fetchActiveJobs = async () => { try { const response = await fetch("/api/jobs/active"); if (response.ok) { const jobs = await response.json(); setActiveJobs(jobs); } } catch (error) { console.error("Failed to fetch jobs:", error); } }; fetchActiveJobs(); const interval = setInterval(fetchActiveJobs, 2000); return () => clearInterval(interval); }, []); // Position the popin relative to the button const updatePosition = useCallback(() => { if (!buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); const isMobile = window.innerWidth < 640; if (isMobile) { setPopinStyle({ position: "fixed", top: `${rect.bottom + 8}px`, left: "12px", right: "12px", }); } else { // Align right edge of popin with right edge of button const rightEdge = window.innerWidth - rect.right; setPopinStyle({ position: "fixed", top: `${rect.bottom + 8}px`, right: `${Math.max(rightEdge, 12)}px`, width: "384px", // w-96 }); } }, []); useEffect(() => { if (!isOpen) return; updatePosition(); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); return () => { window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; }, [isOpen, updatePosition]); // Close when clicking outside useEffect(() => { if (!isOpen) return; const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; if ( buttonRef.current && !buttonRef.current.contains(target) && popinRef.current && !popinRef.current.contains(target) ) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); // Close on Escape useEffect(() => { if (!isOpen) return; const handleEsc = (e: KeyboardEvent) => { if (e.key === "Escape") setIsOpen(false); }; document.addEventListener("keydown", handleEsc); return () => document.removeEventListener("keydown", handleEsc); }, [isOpen]); const runningJobs = activeJobs.filter(j => j.status === "running" || j.status === "extracting_pages" || j.status === "generating_thumbnails"); const pendingJobs = activeJobs.filter(j => j.status === "pending"); const totalCount = activeJobs.length; const totalProgress = runningJobs.reduce((acc, job) => { return acc + (job.progress_percent || 0); }, 0) / (runningJobs.length || 1); if (totalCount === 0) { return ( ); } const popin = isOpen && ( <> {/* Mobile backdrop */}
setIsOpen(false)} aria-hidden="true" /> {/* Popin */}
{/* Header */}
📊

{t("jobsIndicator.activeTasks")}

{runningJobs.length > 0 ? t("jobsIndicator.runningAndPending", { running: runningJobs.length, pending: pendingJobs.length }) : t("jobsIndicator.pendingTasks", { count: pendingJobs.length, plural: pendingJobs.length !== 1 ? "s" : "" }) }

setIsOpen(false)} > {t("jobsIndicator.viewAllLink")}
{/* Overall progress bar if running */} {runningJobs.length > 0 && (
{t("jobsIndicator.overallProgress")} {Math.round(totalProgress)}%
)} {/* Job List */}
{activeJobs.length === 0 ? (

{t("jobsIndicator.noActiveTasks")}

) : (
    {activeJobs.map(job => (
  • setIsOpen(false)} >
    {(job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && } {job.status === "pending" && }
    {job.id.slice(0, 8)} {t(`jobType.${job.type}` as any) !== `jobType.${job.type}` ? t(`jobType.${job.type}` as any) : job.type}
    {(job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && job.progress_percent != null && (
    {job.progress_percent}%
    )} {job.current_file && (

    📄 {job.current_file}

    )} {job.stats_json && (
    ✓ {job.stats_json.indexed_files} {(job.stats_json.warnings ?? 0) > 0 && ( ⚠ {job.stats_json.warnings} )} {job.stats_json.errors > 0 && ( ✕ {job.stats_json.errors} )}
    )}
  • ))}
)}
{/* Footer */}

{t("jobsIndicator.autoRefresh")}

); return ( <> {typeof document !== "undefined" && createPortal(popin, document.body)} ); } // Mini progress bar for dropdown function MiniProgressBar({ value }: { value: number }) { return (
); }