lilith-platform.live/codebase/@features/messages/frontend-user/src/components/JobQueuePanel.tsx

244 lines
6.1 KiB
TypeScript

import { type ReactElement } from 'react';
import styled, { keyframes } from '@lilith/ui-styled-components';
import { X } from 'lucide-react';
import { Link } from '@lilith/ui-router';
import type { JobQueueItem, JobStatus } from '../api/types';
import { useJobQueue } from '../api/relationship';
// ---- Animations ----
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const pulse = keyframes`
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
`;
// ---- Styled ----
const Panel = styled.div<{ $open: boolean }>`
position: fixed;
left: 200px;
bottom: 0;
width: 300px;
max-height: ${({ $open }) => ($open ? '420px' : '0')};
overflow: hidden;
background: ${({ theme }) => theme.colors.background.secondary};
border: 1px solid ${({ theme }) => theme.colors.border};
border-bottom: none;
border-radius: 10px 10px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.35);
z-index: 150;
transition: max-height 0.22s ease;
@media (max-width: 768px) {
left: 0;
width: 100vw;
border-radius: 10px 10px 0 0;
}
`;
const PanelHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
flex-shrink: 0;
`;
const PanelTitle = styled.span`
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${({ theme }) => theme.colors.text.muted};
`;
const CloseBtn = styled.button`
background: none;
border: none;
cursor: pointer;
color: ${({ theme }) => theme.colors.text.muted};
display: flex;
align-items: center;
padding: 2px;
border-radius: 4px;
&:hover {
color: ${({ theme }) => theme.colors.text.primary};
background: ${({ theme }) => theme.colors.hover.primary};
}
`;
const JobList = styled.div`
overflow-y: auto;
max-height: 368px;
padding: 8px 0;
`;
const JobRow = styled.div`
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 14px;
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
&:last-child {
border-bottom: none;
}
&:hover {
background: ${({ theme }) => theme.colors.hover.primary};
}
`;
const StatusDot = styled.div<{ $status: JobStatus }>`
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
background: ${({ $status }) => {
switch ($status) {
case 'pending': return '#888';
case 'running': return '#F5A623';
case 'completed': return '#4CAF82';
case 'failed': return '#c0392b';
}
}};
${({ $status }) => $status === 'running' && `animation: ${pulse} 1.2s ease-in-out infinite;`}
${({ $status }) => $status === 'pending' && `animation: ${spin} 2s linear infinite; border-radius: 50%; border: 2px solid #555; border-top-color: #aaa; background: transparent;`}
`;
const JobInfo = styled.div`
flex: 1;
min-width: 0;
`;
const JobContact = styled(Link)`
display: block;
font-size: 13px;
font-weight: 600;
color: ${({ theme }) => theme.colors.text.primary};
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: ${({ theme }) => theme.colors.accent.main};
}
`;
const JobMeta = styled.div`
display: flex;
gap: 6px;
align-items: center;
margin-top: 2px;
`;
const JobKind = styled.span`
font-size: 10px;
color: ${({ theme }) => theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const JobTime = styled.span`
font-size: 10px;
color: ${({ theme }) => theme.colors.text.muted};
`;
const JobError = styled.div`
margin-top: 3px;
font-size: 11px;
color: #c0392b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const EmptyState = styled.div`
padding: 24px 14px;
font-size: 12px;
color: ${({ theme }) => theme.colors.text.muted};
text-align: center;
`;
// ---- Helpers ----
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
return `${h}h ago`;
}
function statusLabel(status: JobStatus): string {
switch (status) {
case 'pending': return 'queued';
case 'running': return 'running';
case 'completed': return 'done';
case 'failed': return 'failed';
}
}
// ---- Component ----
interface JobQueuePanelProps {
open: boolean;
onClose: () => void;
}
export function JobQueuePanel({ open, onClose }: JobQueuePanelProps): ReactElement {
const { data: jobs = [] } = useJobQueue();
return (
<Panel $open={open} aria-label="Job queue" role="region">
<PanelHeader>
<PanelTitle>Analysis Queue ({jobs.length})</PanelTitle>
<CloseBtn type="button" onClick={onClose} aria-label="Close job queue">
<X size={14} />
</CloseBtn>
</PanelHeader>
<JobList>
{jobs.length === 0 ? (
<EmptyState>No recent jobs</EmptyState>
) : (
jobs.map((job: JobQueueItem) => (
<JobRow key={job.id}>
<StatusDot $status={job.status as JobStatus} aria-label={statusLabel(job.status as JobStatus)} />
<JobInfo>
<JobContact to={`/contacts/${job.contactId}/relationship`}>
{job.contactName ?? job.contactId.slice(0, 8)}
</JobContact>
<JobMeta>
<JobKind>{job.kind.replace(/_/g, ' ')} · {statusLabel(job.status as JobStatus)}</JobKind>
<JobTime>{relativeTime(job.createdAt)}</JobTime>
</JobMeta>
{job.status === 'failed' && job.error && (
<JobError title={job.error}>{job.error}</JobError>
)}
</JobInfo>
</JobRow>
))
)}
</JobList>
</Panel>
);
}
// ---- Active job count (for badge) ----
export function useActiveJobCount(): number {
const { data: jobs = [] } = useJobQueue();
return jobs.filter((j) => j.status === 'pending' || j.status === 'running').length;
}