244 lines
6.1 KiB
TypeScript
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;
|
|
}
|