1// packages/flux_react/src/components/FluxUploadGate.tsx
2//
3// Copy this file into your own project (e.g. components/FluxUploadGate.tsx)
4// and edit the COLORS object, the dialog markup, or the button styles
5// freely — it is yours once it is copied. The only import that still
6// needs to come from the package is checkFileMatch, if you want to
7// keep using Flux's exact matching logic rather than reimplementing it.
8
9import React, { useRef, useState, useCallback } from 'react'
10import { checkFileMatch } from '@tsworldtech/flux_react'
11import type { FileMatch, PendingIntent } from '@tsworldtech/flux_react'
12
13export interface FluxUploadGateProps {
14 pendingIntent: PendingIntent | null
15 onMatch: (file: File, match: FileMatch) => void
16 onMismatch?: (file: File, match: FileMatch) => void
17 onCancel?: () => void
18 accept?: string
19 children?: (props: { open: () => void }) => React.ReactNode
20 className?: string
21}
22
23function formatBytes(bytes: number): string {
24 if (bytes === 0) return '0 B'
25 const k = 1024
26 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
27 const i = Math.floor(Math.log(bytes) / Math.log(k))
28 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
29}
30
31// ── EDIT THIS — your own design tokens, not Flux's defaults ────────
32const COLORS = {
33 surface: 'var(--color-surface, #05242e)',
34 border: 'var(--color-border, #1e3a42)',
35 text: 'var(--color-foreground, #f2fcff)',
36 muted: 'var(--color-muted, #7db8c8)',
37 accent: 'var(--color-accent, #156A80)',
38 amber: '#f59e0b',
39 red: '#ef4444',
40}
41
42function MatchDialog({
43 match, selectedFile, intent, onUploadThis, onPickAgain, onCancel,
44}: {
45 match: 'similar' | 'different'
46 selectedFile: File
47 intent: PendingIntent
48 onUploadThis: () => void
49 onPickAgain: () => void
50 onCancel?: () => void
51}) {
52 const isSimilar = match === 'similar'
53
54 return (
55 <div style={styles.overlay}>
56 <div style={styles.dialog} role="dialog" aria-modal="true">
57 <div style={{ ...styles.iconWrap, backgroundColor: isSimilar ? '#3b2a00' : '#2a0a0a' }}>
58 <span style={{ fontSize: 20 }}>{isSimilar ? '⚠️' : '❌'}</span>
59 </div>
60
61 <h3 style={styles.dialogTitle}>
62 {isSimilar ? 'File may have been modified' : "File doesn't match saved job"}
63 </h3>
64
65 <p style={styles.dialogBody}>
66 {isSimilar ? (
67 <>This file looks like it may have been modified since you queued this job.
68 The original was <strong>{intent.fileName}</strong> ({formatBytes(intent.fileSize)}).
69 Continue anyway?</>
70 ) : (
71 <>The selected file (<strong>{selectedFile.name}</strong>, {formatBytes(selectedFile.size)})
72 doesn't match your saved job (<strong>{intent.fileName}</strong>, {formatBytes(intent.fileSize)}).
73 Upload this file instead, or pick the correct one?</>
74 )}
75 </p>
76
77 <div style={styles.dialogActions}>
78 {isSimilar ? (
79 <>
80 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onUploadThis} type="button">Upload This</button>
81 <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onPickAgain} type="button">Pick Again</button>
82 </>
83 ) : (
84 <>
85 <button style={{ ...styles.btn, ...styles.btnWarn }} onClick={onUploadThis} type="button">Upload This Anyway</button>
86 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onPickAgain} type="button">Pick Correct File</button>
87 {onCancel && <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onCancel} type="button">Cancel</button>}
88 </>
89 )}
90 </div>
91 </div>
92 </div>
93 )
94}
95
96export function FluxUploadGate({
97 pendingIntent, onMatch, onMismatch, onCancel, accept, children, className,
98}: FluxUploadGateProps) {
99 const inputRef = useRef<HTMLInputElement>(null)
100 const [pendingFile, setPendingFile] = useState<File | null>(null)
101 const [pendingMatch, setPendingMatch] = useState<FileMatch | null>(null)
102
103 const open = useCallback(() => inputRef.current?.click(), [])
104
105 const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
106 const file = e.target.files?.[0]
107 if (!file) return
108 e.target.value = ''
109
110 if (!pendingIntent) { onMatch(file, 'exact'); return }
111
112 const match = checkFileMatch(pendingIntent, file)
113 if (match === 'exact') { onMatch(file, 'exact'); return }
114 if (onMismatch) { onMismatch(file, match); return }
115
116 setPendingFile(file)
117 setPendingMatch(match)
118 }, [pendingIntent, onMatch, onMismatch])
119
120 const handleUploadThis = useCallback(() => {
121 if (pendingFile && pendingMatch) onMatch(pendingFile, pendingMatch)
122 setPendingFile(null)
123 setPendingMatch(null)
124 }, [pendingFile, pendingMatch, onMatch])
125
126 const handlePickAgain = useCallback(() => {
127 setPendingFile(null)
128 setPendingMatch(null)
129 setTimeout(() => inputRef.current?.click(), 50)
130 }, [])
131
132 const handleCancel = useCallback(() => {
133 setPendingFile(null)
134 setPendingMatch(null)
135 onCancel?.()
136 }, [onCancel])
137
138 const acceptType = accept ?? pendingIntent?.fileType ?? undefined
139
140 return (
141 <div style={{ display: 'contents' }} className={className}>
142 <input ref={inputRef} type="file" accept={acceptType} style={{ display: 'none' }} onChange={handleChange} aria-hidden="true" />
143
144 {children ? children({ open }) : (
145 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={open} type="button">Re-select File</button>
146 )}
147
148 {pendingFile && pendingMatch && pendingMatch !== 'exact' && pendingIntent && (
149 <MatchDialog
150 match={pendingMatch as 'similar' | 'different'}
151 selectedFile={pendingFile}
152 intent={pendingIntent}
153 onUploadThis={handleUploadThis}
154 onPickAgain={handlePickAgain}
155 onCancel={pendingMatch === 'different' ? handleCancel : undefined}
156 />
157 )}
158 </div>
159 )
160}
161
162const styles: Record<string, React.CSSProperties> = {
163 overlay: { position: 'fixed', inset: 0, backgroundColor: 'rgba(0, 13, 17, 0.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, backdropFilter: 'blur(4px)' },
164 dialog: { display: 'flex', flexDirection: 'column', gap: 16, padding: 24, borderRadius: 12, border: `1px solid ${COLORS.border}`, backgroundColor: COLORS.surface, color: COLORS.text, fontFamily: 'inherit', fontSize: 14, maxWidth: 420, width: 'calc(100vw - 48px)', boxShadow: '0 24px 48px rgba(0,0,0,0.5)' },
165 iconWrap: { alignSelf: 'flex-start', padding: '8px 10px', borderRadius: 8 },
166 dialogTitle: { margin: 0, fontSize: 16, fontWeight: 700, color: COLORS.text, lineHeight: 1.3 },
167 dialogBody: { margin: 0, color: COLORS.muted, lineHeight: 1.6 },
168 dialogActions: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 },
169 btn: { padding: '8px 14px', borderRadius: 6, fontSize: 13, fontWeight: 600, cursor: 'pointer', lineHeight: 1, border: '1px solid transparent', transition: 'opacity 0.15s' },
170 btnPrimary: { backgroundColor: COLORS.accent, color: '#fff', border: `1px solid ${COLORS.accent}` },
171 btnWarn: { backgroundColor: 'transparent', color: COLORS.amber, border: `1px solid ${COLORS.amber}` },
172 btnGhost: { backgroundColor: 'transparent', color: COLORS.muted, border: `1px solid ${COLORS.border}` },
173}