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 theme CSS variable mappings, and
5// isCancelledJob() to match your own backend's cancel convention
6// instead of the '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 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
47function ProgressBar({ value }: { value: number }) {
48 const clamped = Math.max(0, Math.min(100, value))
49 return (
50 <div style={styles.progressTrack}>
51 <div style={{ ...styles.progressFill, width: `${clamped}%` }} />
52 </div>
53 )
54}
55
56function StatusDot({ color }: { color: string }) {
57 return (
58 <span
59 style={{
60 display: 'inline-block',
61 width: 8,
62 height: 8,
63 borderRadius: '50%',
64 backgroundColor: color,
65 flexShrink: 0,
66 }}
67 />
68 )
69}
70
71export function FluxJobPanel({
72 job, progress, status, isQueued, pendingIntent, isOnline, onRequestReselect, className,
73}: FluxJobPanelProps) {
74
75 if (isQueued && pendingIntent && !isOnline) {
76 return (
77 <div style={styles.panel} className={className}>
78 <div style={styles.row}>
79 <StatusDot color="var(--color-muted)" />
80 <span style={styles.label}>Saved offline</span>
81 </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: 'var(--color-accent)' }} className={className}>
91 <div style={styles.row}>
92 <StatusDot color="var(--color-accent)" />
93 <span style={styles.label}>Ready to upload</span>
94 </div>
95 <p style={styles.meta}>{pendingIntent.fileName} · {formatBytes(pendingIntent.fileSize)}</p>
96 <p style={styles.hint}>You're back online. Re-select the file to continue.</p>
97 <button style={styles.button} onClick={onRequestReselect} type="button">Re-select File</button>
98 </div>
99 )
100 }
101
102 if (status === 'complete') {
103 return (
104 <div style={styles.panel} className={className}>
105 <div style={styles.row}>
106 <StatusDot color="var(--color-primary)" />
107 <span style={styles.label}>Job complete</span>
108 </div>
109 {job && <p style={styles.meta}>ID: {job.id}</p>}
110 </div>
111 )
112 }
113
114 if (status === 'failed' && isCancelledJob(job)) {
115 return (
116 <div style={{ ...styles.panel, borderColor: 'var(--color-muted)' }} className={className}>
117 <div style={styles.row}>
118 <StatusDot color="var(--color-muted)" />
119 <span style={styles.label}>Job cancelled</span>
120 </div>
121 {job && <p style={styles.meta}>ID: {job.id}</p>}
122 </div>
123 )
124 }
125
126 if (status === 'failed') {
127 return (
128 <div style={{ ...styles.panel, borderColor: 'var(--color-faint)' }} className={className}>
129 <div style={styles.row}>
130 <StatusDot color="var(--color-faint)" />
131 <span style={styles.label}>Job failed</span>
132 </div>
133 {job?.error && <p style={styles.hint}>{job.error}</p>}
134 {job && <p style={styles.meta}>ID: {job.id}</p>}
135 </div>
136 )
137 }
138
139 if (status === 'running' && job) {
140 return (
141 <div style={styles.panel} className={className}>
142 <div style={styles.row}>
143 <StatusDot color="var(--color-accent)" />
144 <span style={styles.label}>{job.label ?? 'Processing…'}</span>
145 <span style={styles.pct}>{Math.round(progress)}%</span>
146 </div>
147 <ProgressBar value={progress} />
148 <p style={styles.meta}>ID: {job.id}</p>
149 </div>
150 )
151 }
152
153 return (
154 <div style={{ ...styles.panel, ...styles.idle }} className={className}>
155 <span style={styles.idleText}>No active job</span>
156 </div>
157 )
158}
159
160const styles: Record<string, React.CSSProperties> = {
161 panel: {
162 display: 'flex',
163 flexDirection: 'column',
164 gap: 8,
165 padding: '14px 16px',
166 borderRadius: 10,
167 border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)',
168 backgroundColor: 'var(--color-surface)',
169 color: 'var(--color-primary)',
170 fontFamily: 'inherit',
171 fontSize: 14,
172 minWidth: 240,
173 },
174 row: { display: 'flex', alignItems: 'center', gap: 8 },
175 label: { fontWeight: 600, flex: 1, color: 'var(--color-primary)' },
176 pct: { fontSize: 13, color: 'var(--color-muted)', fontVariantNumeric: 'tabular-nums' },
177 meta: { margin: 0, fontSize: 12, color: 'var(--color-muted)' },
178 hint: { margin: 0, fontSize: 12, color: 'var(--color-muted)', lineHeight: 1.5 },
179 progressTrack: {
180 height: 6,
181 borderRadius: 3,
182 backgroundColor: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
183 overflow: 'hidden',
184 },
185 progressFill: {
186 height: '100%',
187 borderRadius: 3,
188 backgroundColor: 'var(--color-accent)',
189 transition: 'width 0.2s ease',
190 },
191 button: {
192 alignSelf: 'flex-start',
193 padding: '6px 12px',
194 borderRadius: 6,
195 border: '1px solid var(--color-accent)',
196 backgroundColor: 'transparent',
197 color: 'var(--color-accent)',
198 fontSize: 13,
199 fontWeight: 600,
200 cursor: 'pointer',
201 lineHeight: 1,
202 },
203 idle: { alignItems: 'center', justifyContent: 'center', padding: '20px 16px' },
204 idleText: { color: 'var(--color-muted)', fontSize: 13 },
205};