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's yours once it's 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(--surface, #05242e)',
34 border: 'var(--border, #1e3a42)',
35 text: 'var(--text, #f2fcff)',
36 muted: 'var(--muted, #7db8c8)',
37 accent: 'var(--accent, #156A80)', // swapped to match your teal,
38 // not the package default blue
39 amber: '#f59e0b',
40 red: '#ef4444',
41}
42
43function MatchDialog({
44 match, selectedFile, intent, onUploadThis, onPickAgain, onCancel,
45}: {
46 match: 'similar' | 'different'
47 selectedFile: File
48 intent: PendingIntent
49 onUploadThis: () => void
50 onPickAgain: () => void
51 onCancel?: () => void
52}) {
53 const isSimilar = match === 'similar'
54
55 return (
56 <div style={styles.overlay}>
57 <div style={styles.dialog} role="dialog" aria-modal="true">
58 <div style={{ ...styles.iconWrap, backgroundColor: isSimilar ? '#3b2a00' : '#2a0a0a' }}>
59 <span style={{ fontSize: 20 }}>{isSimilar ? '⚠️' : '❌'}</span>
60 </div>
61
62 <h3 style={styles.dialogTitle}>
63 {isSimilar ? 'File may have been modified' : "File doesn't match saved job"}
64 </h3>
65
66 <p style={styles.dialogBody}>
67 {isSimilar ? (
68 <>This file looks like it may have been modified since you queued this job.
69 The original was <strong>{intent.fileName}</strong> ({formatBytes(intent.fileSize)}).
70 Continue anyway?</>
71 ) : (
72 <>The selected file (<strong>{selectedFile.name}</strong>, {formatBytes(selectedFile.size)})
73 doesn't match your saved job (<strong>{intent.fileName}</strong>, {formatBytes(intent.fileSize)}).
74 Upload this file instead, or pick the correct one?</>
75 )}
76 </p>
77
78 <div style={styles.dialogActions}>
79 {isSimilar ? (
80 <>
81 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onUploadThis} type="button">Upload This</button>
82 <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onPickAgain} type="button">Pick Again</button>
83 </>
84 ) : (
85 <>
86 <button style={{ ...styles.btn, ...styles.btnWarn }} onClick={onUploadThis} type="button">Upload This Anyway</button>
87 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={onPickAgain} type="button">Pick Correct File</button>
88 {onCancel && <button style={{ ...styles.btn, ...styles.btnGhost }} onClick={onCancel} type="button">Cancel</button>}
89 </>
90 )}
91 </div>
92 </div>
93 </div>
94 )
95}
96
97export function FluxUploadGate({
98 pendingIntent, onMatch, onMismatch, onCancel, accept, children, className,
99}: FluxUploadGateProps) {
100 const inputRef = useRef<HTMLInputElement>(null)
101 const [pendingFile, setPendingFile] = useState<File | null>(null)
102 const [pendingMatch, setPendingMatch] = useState<FileMatch | null>(null)
103
104 const open = useCallback(() => inputRef.current?.click(), [])
105
106 const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
107 const file = e.target.files?.[0]
108 if (!file) return
109 e.target.value = ''
110
111 if (!pendingIntent) { onMatch(file, 'exact'); return }
112
113 const match = checkFileMatch(pendingIntent, file)
114 if (match === 'exact') { onMatch(file, 'exact'); return }
115 if (onMismatch) { onMismatch(file, match); return }
116
117 setPendingFile(file)
118 setPendingMatch(match)
119 }, [pendingIntent, onMatch, onMismatch])
120
121 const handleUploadThis = useCallback(() => {
122 if (pendingFile && pendingMatch) onMatch(pendingFile, pendingMatch)
123 setPendingFile(null)
124 setPendingMatch(null)
125 }, [pendingFile, pendingMatch, onMatch])
126
127 const handlePickAgain = useCallback(() => {
128 setPendingFile(null)
129 setPendingMatch(null)
130 setTimeout(() => inputRef.current?.click(), 50)
131 }, [])
132
133 const handleCancel = useCallback(() => {
134 setPendingFile(null)
135 setPendingMatch(null)
136 onCancel?.()
137 }, [onCancel])
138
139 const acceptType = accept ?? pendingIntent?.fileType ?? undefined
140
141 return (
142 <div style={{ display: 'contents' }} className={className}>
143 <input ref={inputRef} type="file" accept={acceptType} style={{ display: 'none' }} onChange={handleChange} aria-hidden="true" />
144
145 {children ? children({ open }) : (
146 <button style={{ ...styles.btn, ...styles.btnPrimary }} onClick={open} type="button">Re-select File</button>
147 )}
148
149 {pendingFile && pendingMatch && pendingMatch !== 'exact' && pendingIntent && (
150 <MatchDialog
151 match={pendingMatch as 'similar' | 'different'}
152 selectedFile={pendingFile}
153 intent={pendingIntent}
154 onUploadThis={handleUploadThis}
155 onPickAgain={handlePickAgain}
156 onCancel={pendingMatch === 'different' ? handleCancel : undefined}
157 />
158 )}
159 </div>
160 )
161}
162
163const styles: Record<string, React.CSSProperties> = {
164 overlay: { position: 'fixed', inset: 0, backgroundColor: 'rgba(0, 13, 17, 0.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, backdropFilter: 'blur(4px)' },
165 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)' },
166 iconWrap: { alignSelf: 'flex-start', padding: '8px 10px', borderRadius: 8 },
167 dialogTitle: { margin: 0, fontSize: 16, fontWeight: 700, color: COLORS.text, lineHeight: 1.3 },
168 dialogBody: { margin: 0, color: COLORS.muted, lineHeight: 1.6 },
169 dialogActions: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 },
170 btn: { padding: '8px 14px', borderRadius: 6, fontSize: 13, fontWeight: 600, cursor: 'pointer', lineHeight: 1, border: '1px solid transparent', transition: 'opacity 0.15s' },
171 btnPrimary: { backgroundColor: COLORS.accent, color: '#fff', border: `1px solid ${COLORS.accent}` },
172 btnWarn: { backgroundColor: 'transparent', color: COLORS.amber, border: `1px solid ${COLORS.amber}` },
173 btnGhost: { backgroundColor: 'transparent', color: COLORS.muted, border: `1px solid ${COLORS.border}` },
174}