1// packages/flux-react/src/components/FluxJobPanel.tsx
2//
3// Copy into your own project. The two edits almost everyone will
4// want to make immediately: the COLORS object, and isCancelledJob()
5// to match your own backend's cancel convention instead of the
6// 'Cancelled by user' default.
7
8import React from 'react'
9
10export type JobStatus = 'idle' | 'running' | 'complete' | 'failed'
11
12export interface PendingIntent {
13 label: string
14 fileName: string
15 fileSize: number
16 fileType: string
17 lastModified: number
18}
19
20export interface FluxJobPanelProps {
21 job: { id: string; label?: string; error?: string | null; [key: string]: any } | null
22 progress: number
23 status: JobStatus
24 isQueued: boolean
25 pendingIntent: PendingIntent | null
26 isOnline: boolean
27 onRequestReselect?: () => void
28 className?: string
29}
30
31function formatBytes(bytes: number): string {
32 if (bytes === 0) return '0 B'
33 const k = 1024
34 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
35 const i = Math.floor(Math.log(bytes) / Math.log(k))
36 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
37}
38
39// ── EDIT THIS — match your own cancel-endpoint's convention ────────
40function isCancelledJob(job: FluxJobPanelProps['job']): boolean {
41 return job?.error === 'Cancelled by user' // <- your string here,
42 // or a different field
43 // entirely (job.reason,
44 // job.cancelledBy, etc)
45}
46
47// ── EDIT THIS — your own design tokens, not Flux's defaults ────────
48const COLORS = {
49 border: 'var(--flux-job-border, #1e3a42)',
50 surface: 'var(--flux-job-surface, #05242e)',
51 text: 'var(--flux-job-text, #f2fcff)',
52 muted: 'var(--flux-job-muted, #7db8c8)',
53 accent: 'var(--flux-job-accent, #156A80)', // swapped from package
54 // default blue to
55 // your site teal
56 green: '#22c55e',
57 amber: '#f59e0b',
58 red: '#ef4444',
59}
60
61function ProgressBar({ value }: { value: number }) {
62 const clamped = Math.max(0, Math.min(100, value))
63 return (
64 <div style={styles.progressTrack}>
65 <div style={{ ...styles.progressFill, width: `${clamped}%` }} />
66 </div>
67 )
68}
69
70function StatusDot({ color }: { color: string }) {
71 return <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', backgroundColor: color, flexShrink: 0 }} />
72}
73
74export function FluxJobPanel({
75 job, progress, status, isQueued, pendingIntent, isOnline, onRequestReselect, className,
76}: FluxJobPanelProps) {
77
78 if (isQueued && pendingIntent && !isOnline) {
79 return (
80 <div style={styles.panel} className={className}>
81 <div style={styles.row}><StatusDot color={COLORS.amber} /><span style={styles.label}>Saved offline</span></div>
82 <p style={styles.meta}>{pendingIntent.fileName} · {formatBytes(pendingIntent.fileSize)}</p>
83 <p style={styles.hint}>Reconnect to upload and start this job.</p>
84 </div>
85 )
86 }
87
88 if (isQueued && pendingIntent && isOnline) {
89 return (
90 <div style={{ ...styles.panel, borderColor: COLORS.amber }} className={className}>
91 <div style={styles.row}><StatusDot color={COLORS.amber} /><span style={styles.label}>Ready to upload</span></div>
92 <p style={styles.meta}>{pendingIntent.fileName} · {formatBytes(pendingIntent.fileSize)}</p>
93 <p style={styles.hint}>You're back online. Re-select the file to continue.</p>
94 <button style={styles.button} onClick={onRequestReselect} type="button">Re-select File</button>
95 </div>
96 )
97 }
98
99 if (status === 'complete') {
100 return (
101 <div style={styles.panel} className={className}>
102 <div style={styles.row}><StatusDot color={COLORS.green} /><span style={styles.label}>Job complete</span></div>
103 {job && <p style={styles.meta}>ID: {job.id}</p>}
104 </div>
105 )
106 }
107
108 if (status === 'failed' && isCancelledJob(job)) {
109 return (
110 <div style={{ ...styles.panel, borderColor: COLORS.amber }} className={className}>
111 <div style={styles.row}><StatusDot color={COLORS.amber} /><span style={styles.label}>Job cancelled</span></div>
112 {job && <p style={styles.meta}>ID: {job.id}</p>}
113 </div>
114 )
115 }
116
117 if (status === 'failed') {
118 return (
119 <div style={{ ...styles.panel, borderColor: COLORS.red }} className={className}>
120 <div style={styles.row}><StatusDot color={COLORS.red} /><span style={styles.label}>Job failed</span></div>
121 {job?.error && <p style={styles.hint}>{job.error}</p>}
122 {job && <p style={styles.meta}>ID: {job.id}</p>}
123 </div>
124 )
125 }
126
127 if (status === 'running' && job) {
128 return (
129 <div style={styles.panel} className={className}>
130 <div style={styles.row}>
131 <StatusDot color={COLORS.accent} />
132 <span style={styles.label}>{job.label ?? 'Processing…'}</span>
133 <span style={styles.pct}>{Math.round(progress)}%</span>
134 </div>
135 <ProgressBar value={progress} />
136 <p style={styles.meta}>ID: {job.id}</p>
137 </div>
138 )
139 }
140
141 return (
142 <div style={{ ...styles.panel, ...styles.idle }} className={className}>
143 <span style={styles.idleText}>No active job</span>
144 </div>
145 )
146}
147
148const styles: Record<string, React.CSSProperties> = {
149 panel: { display: 'flex', flexDirection: 'column', gap: 8, padding: '14px 16px', borderRadius: 10, border: `1px solid ${COLORS.border}`, backgroundColor: COLORS.surface, color: COLORS.text, fontFamily: 'inherit', fontSize: 14, minWidth: 240 },
150 row: { display: 'flex', alignItems: 'center', gap: 8 },
151 label: { fontWeight: 600, flex: 1, color: COLORS.text },
152 pct: { fontSize: 13, color: COLORS.muted, fontVariantNumeric: 'tabular-nums' },
153 meta: { margin: 0, fontSize: 12, color: COLORS.muted },
154 hint: { margin: 0, fontSize: 12, color: COLORS.muted, lineHeight: 1.5 },
155 progressTrack: { height: 6, borderRadius: 3, backgroundColor: '#0d3340', overflow: 'hidden' },
156 progressFill: { height: '100%', borderRadius: 3, backgroundColor: COLORS.accent, transition: 'width 0.2s ease' },
157 button: { alignSelf: 'flex-start', padding: '6px 12px', borderRadius: 6, border: `1px solid ${COLORS.accent}`, backgroundColor: 'transparent', color: COLORS.accent, fontSize: 13, fontWeight: 600, cursor: 'pointer', lineHeight: 1 },
158 idle: { alignItems: 'center', justifyContent: 'center', padding: '20px 16px' },
159 idleText: { color: COLORS.muted, fontSize: 13 },
160}