update ui

This commit is contained in:
infrost
2026-07-08 17:23:09 +01:00
parent c4d591efc3
commit cb901e20e6
6 changed files with 166 additions and 11 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ export function App() {
return (
<div style={styles.container}>
<header style={styles.header}>
<span style={styles.logo}> QR-over-GIF</span>
<span style={styles.logo}> RaptorQR</span>
<nav style={styles.tabBar}>
<button
style={{ ...styles.tab, ...(tab === 'sender' ? styles.tabActive : {}) }}
+102 -3
View File
@@ -194,6 +194,17 @@ const S = {
resize: 'vertical' as const,
minHeight: 120,
} as CSSProps,
hiddenInput: {
display: 'none',
} as CSSProps,
fileName: {
color: '#c9d1d9',
fontSize: 14,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
} as CSSProps,
checkboxLabel: {
display: 'inline-flex',
alignItems: 'center',
@@ -211,6 +222,8 @@ export function ReceiverPage() {
const videoContainerRef = useRef<HTMLDivElement>(null);
const textResultRef = useRef<HTMLDivElement>(null);
const fileResultRef = useRef<HTMLDivElement>(null);
const gifFileInputRef = useRef<HTMLInputElement>(null);
const copyResetTimerRef = useRef<number | null>(null);
const workerRef = useRef<Worker | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const animRef = useRef<number>(0);
@@ -227,6 +240,8 @@ export function ReceiverPage() {
const [neededPackets, setNeededPackets] = useState(0);
const [receivedFile, setReceivedFile] = useState<ReceivedFile | null>(null);
const [receivedText, setReceivedText] = useState('');
const [textCopied, setTextCopied] = useState(false);
const [gifFileName, setGifFileName] = useState('');
const [error, setError] = useState('');
const [zoomLevel, setZoomLevel] = useState(1);
const [hasZoomSupport, setHasZoomSupport] = useState(false);
@@ -340,6 +355,7 @@ export function ReceiverPage() {
case 'complete': {
if (msg.isText) {
setReceivedText(msg.text);
setTextCopied(false);
setReceivedFile(null);
} else {
setReceivedFile({
@@ -348,6 +364,7 @@ export function ReceiverPage() {
mime: msg.mime ?? 'application/octet-stream',
});
setReceivedText('');
setTextCopied(false);
}
const elapsed = scanStartRef.current > 0 ? Date.now() - scanStartRef.current : 0;
setElapsedMs(elapsed);
@@ -448,6 +465,7 @@ export function ReceiverPage() {
setError('');
setReceivedFile(null);
setReceivedText('');
setTextCopied(false);
setTotalFrames(0);
setFramesWithQR(0);
setUniquePackets(0);
@@ -530,9 +548,11 @@ export function ReceiverPage() {
const file = input.files?.[0];
if (!file) return;
setGifFileName(`${file.name} · ${formatBytes(file.size)}`);
setError('');
setReceivedFile(null);
setReceivedText('');
setTextCopied(false);
setTotalFrames(0);
setFramesWithQR(0);
setUniquePackets(0);
@@ -666,6 +686,30 @@ export function ReceiverPage() {
URL.revokeObjectURL(url);
}, [receivedFile]);
const handleChooseGifFile = useCallback(() => {
gifFileInputRef.current?.click();
}, []);
const handleCopyRecoveredText = useCallback(async () => {
if (!receivedText) return;
try {
await copyTextToClipboard(receivedText);
setTextCopied(true);
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = window.setTimeout(() => {
setTextCopied(false);
copyResetTimerRef.current = null;
}, 1500);
} catch (err: any) {
setError(`Copy failed: ${err.message ?? String(err)}`);
}
}, [receivedText]);
// ── Cleanup on unmount ─────────────────────────────────────────────────
useEffect(() => {
return () => {
@@ -676,6 +720,9 @@ export function ReceiverPage() {
if (workerRef.current) {
workerRef.current.terminate();
}
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
};
}, []);
@@ -974,9 +1021,28 @@ export function ReceiverPage() {
<div style={S.section}>
<div style={S.label}>Upload GIF</div>
<p style={{ fontSize: 13, color: '#8b949e', marginBottom: 8 }}>
Upload a QR-over-GIF file generated by the Sender.
Upload a RaptorQR file generated by the Sender.
</p>
<input type="file" accept=".gif,image/gif" onChange={handleGifFile} />
<div style={S.row}>
<input
ref={gifFileInputRef}
type="file"
accept=".gif,image/gif"
style={S.hiddenInput}
onChange={handleGifFile}
/>
<button
type="button"
style={S.btnSecondary}
disabled={scanning}
onClick={handleChooseGifFile}
>
Choose GIF
</button>
<span style={S.fileName} title={gifFileName}>
{scanning ? 'Processing GIF…' : gifFileName || 'No GIF selected'}
</span>
</div>
{scanning && (
<div style={S.statsBar}>
<span>
@@ -1051,7 +1117,16 @@ export function ReceiverPage() {
{/* ── Received text ────────────────────────────────────────────────────────────────── */}
{receivedText && (
<div style={S.section} ref={textResultRef}>
<div style={S.label}>Recovered Text</div>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ ...S.label, marginBottom: 0 }}>Recovered Text</div>
<button
type="button"
style={S.btnSecondary}
onClick={handleCopyRecoveredText}
>
{textCopied ? 'Copied' : 'Copy text'}
</button>
</div>
<textarea
style={S.textarea}
value={receivedText}
@@ -1086,6 +1161,30 @@ function formatBytes(n: number): string {
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
try {
if (!document.execCommand('copy')) {
throw new Error('Clipboard command was rejected.');
}
} finally {
document.body.removeChild(textarea);
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.floor(ms / 1000);
+34 -2
View File
@@ -205,6 +205,17 @@ const S = {
padding: '9px 10px',
fontSize: 14,
} as CSSProps,
hiddenInput: {
display: 'none',
} as CSSProps,
fileName: {
color: '#c9d1d9',
fontSize: 14,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
} as CSSProps,
warn: {
background: '#3d2600',
border: '1px solid #bb8009',
@@ -280,6 +291,7 @@ export function SenderPage() {
const [fullscreenActive, setFullscreenActive] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const qrStageRef = useRef<HTMLDivElement | null>(null);
const liveTransferRef = useRef<LiveTransfer | null>(null);
const encodeWorkerRef = useRef<Worker | null>(null);
@@ -697,6 +709,10 @@ export function SenderPage() {
resetOutput();
}, [resetOutput]);
const handleChooseFile = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleQRVersionChange = useCallback((value: string) => {
setQrVersion(parseQRVersionOption(value));
resetOutput();
@@ -974,8 +990,24 @@ export function SenderPage() {
onInput={(e) => handleTextChange((e.target as HTMLTextAreaElement).value)}
/>
) : (
<div style={{ marginTop: 10 }}>
<input type="file" onChange={handleFile} />
<div style={{ ...S.row, marginTop: 10 }}>
<input
ref={fileInputRef}
type="file"
style={S.hiddenInput}
onChange={handleFile}
/>
<button
type="button"
style={S.btnSecondary}
disabled={encodingLive}
onClick={handleChooseFile}
>
Choose file
</button>
<span style={S.fileName} title={file?.name ?? ''}>
{file ? `${file.name} · ${formatBytes(file.size)}` : 'No file selected'}
</span>
</div>
)}
</div>