diff --git a/config/2026/config.json b/config/2026/config.json index 12e9c2132..26aaebdcd 100644 --- a/config/2026/config.json +++ b/config/2026/config.json @@ -1 +1,418 @@ -{"title":"QRScout","page_title":"Rebuilt","delimiter":"\t","teamNumber":1100,"theme":{"light":{"background":"0 0% 100%","foreground":"0 0% 3.9%","card":"0 0% 100%","card_foreground":"0 0% 3.9%","popover":"0 0% 100%","popover_foreground":"0 0% 3.9%","primary":"354.44 71.3% 47.9%","primary_foreground":"0 85.7% 97.3%","secondary":"0 0% 96.1%","secondary_foreground":"0 0% 9%","muted":"0 0% 96.1%","muted_foreground":"0 0% 45.1%","accent":"0 0% 96.1%","accent_foreground":"0 0% 9%","destructive":"0 84.2% 60.2%","destructive_foreground":"0 0% 98%","border":"0 0% 89.8%","input":"0 0% 89.8%","ring":"354.44 71.3% 47.9%","chart_1":"12 76% 61%","chart_2":"173 58% 39%","chart_3":"197 37% 24%","chart_4":"43 74% 66%","chart_5":"27 87% 67%"},"dark":{"background":"0 0% 3.9%","foreground":"0 0% 98%","card":"0 0% 3.9%","card_foreground":"0 0% 98%","popover":"0 0% 3.9%","popover_foreground":"0 0% 98%","primary":"354.44 71.3% 47.9%","primary_foreground":"0 85.7% 97.3%","secondary":"0 0% 14.9%","secondary_foreground":"0 0% 98%","muted":"0 0% 14.9%","muted_foreground":"0 0% 63.9%","accent":"0 0% 14.9%","accent_foreground":"0 0% 98%","destructive":"0 62.8% 30.6%","destructive_foreground":"0 0% 98%","border":"0 0% 14.9%","input":"0 0% 14.9%","ring":"354.44 71.3% 47.9%","chart_1":"220 70% 50%","chart_2":"160 60% 45%","chart_3":"30 80% 55%","chart_4":"280 65% 60%","chart_5":"340 75% 55%"}},"sections":[{"name":"Prematch","fields":[{"title":"Scouter Initials","description":"Enter the initials of the scouter.","type":"text","required":true,"code":"scouter","formResetBehavior":"preserve","defaultValue":""},{"title":"Match Number","description":"Enter the match number.","type":"number","required":true,"code":"matchNumber","formResetBehavior":"increment","defaultValue":1},{"title":"Team Number","description":"The team number of the robot you're scouting.","type":"number","required":true,"code":"teamNumber","formResetBehavior":"reset","defaultValue":0,"min":0,"max":99999},{"title":"No Show","description":"Check if the robot did not show up for the match.","type":"boolean","required":false,"code":"noShow","formResetBehavior":"reset","defaultValue":false}]},{"name":"Auto","color":"#3b82f6","fields":[{"title":"HUB (+1)","description":"HUB1","type":"counter","required":false,"code":"HAuto1","formResetBehavior":"reset","defaultValue":0,"min":0,"step":1},{"title":"HUB (+3)","description":"HUB3","type":"counter","required":false,"code":"HAuto3","formResetBehavior":"reset","defaultValue":0,"min":0,"step":3},{"title":"HUB (+5)","description":"HUB5","type":"counter","required":false,"code":"HAuto5","formResetBehavior":"reset","defaultValue":0,"min":0,"step":5},{"title":"FERRIED (+1)","description":"FER1","type":"counter","required":false,"code":"FAuto1","formResetBehavior":"reset","defaultValue":0,"min":0,"step":1},{"title":"FERRIED (+3)","description":"FER3","type":"counter","required":false,"code":"FAuto3","formResetBehavior":"reset","defaultValue":0,"min":0,"step":3},{"title":"FERRIED (+5)","description":"FER5","type":"counter","required":false,"code":"FAuto5","formResetBehavior":"reset","defaultValue":0,"min":0,"step":5},{"title":"Auto Climb Position","type":"select","required":true,"code":"climbauto","formResetBehavior":"reset","defaultValue":"Nothing","choices":{"No Climb":"No Climb","Left":"Left","Middle":"Middle","Right":"Right"}},{"title":"Climbed Successfully?","description":"Did the robot get off the gound?","type":"boolean","required":false,"code":"noShow","formResetBehavior":"reset","defaultValue":false},{"title":"Accuracy?","type":"select","required":true,"code":"Accuracy?A","formResetBehavior":"reset","defaultValue":"","choices":{"Didn't shoot":"Didn't shoot","1.00":"100%",".945":"90-99%",".8":"70-90%",".6":"50-70%",".5":"50%",".4":"30-50%",".2":"10-30%",".05":"1-10%","0.0":"0%"}}]},{"name":"Teleop","color":"#bf9000","fields":[{"title":"HUB (+1)","description":"HUB1","type":"counter","required":false,"code":"HTele1","formResetBehavior":"reset","defaultValue":0,"min":0,"step":1},{"title":"HUB (+3)","description":"HUB3","type":"counter","required":false,"code":"HTele3","formResetBehavior":"reset","defaultValue":0,"min":0,"step":3},{"title":"HUB (+5)","description":"HUB5","type":"counter","required":false,"code":"HTele5","formResetBehavior":"reset","defaultValue":0,"min":0,"step":5},{"title":"FERRIED (+1)","description":"FER1","type":"counter","required":false,"code":"FTele1","formResetBehavior":"reset","defaultValue":0,"min":0,"step":1},{"title":"FERRIED (+3)","description":"FER3","type":"counter","required":false,"code":"FTele3","formResetBehavior":"reset","defaultValue":0,"min":0,"step":3},{"title":"FERRIED (+5)","description":"FER5","type":"counter","required":false,"code":"FTele5","formResetBehavior":"reset","defaultValue":0,"min":0,"step":5},{"title":"Accuracy?","type":"select","required":true,"code":"Accuracy?C","formResetBehavior":"reset","defaultValue":"","choices":{"Didn't shoot":"Didn't shoot","1.00":"100%",".945":"90-99%",".8":"70-90%",".6":"50-70%",".5":"50%",".4":"30-50%",".2":"10-30%",".05":"1-10%","0.0":"0%"}}]},{"name":"Postmatch","color":"#2a8525","fields":[{"title":"Climb","type":"select","required":true,"code":"endPos","formResetBehavior":"reset","defaultValue":"No Climb","choices":{"No Climb":"No Climb","Failed Climb":"Failed Climb","L1":"L1 Climb","L2":"L2 Climb","L3":"L3 Climb"}},{"title":"Crossed Trench?","type":"boolean","required":false,"code":"CFPDT","formResetBehavior":"reset","defaultValue":false},{"title":"Crossed Bump?","type":"select","required":true,"code":"Bump?C","formResetBehavior":"reset","defaultValue":"Did Not Cross","choices":{"Did Not Cross":"Did Not Cross","Got stuck":"Got stuck","Difficulties":"Had Some Difficulties","Crossed Well":"Crossed Well"}},{"title":"Issues during Match","type":"select","required":true,"code":"IdM","formResetBehavior":"reset","defaultValue":"No Problems","choices":{"No Problems":"No Problems","Died and Stayed Dead":"Died and Stayed Dead","Died and Came Back":"Died and Came Back","Fell Over":"Fell Over","Brown Out":"Brown Out","Other Mechanical Issue":"Other Mechanical Issue"}},{"title":"Defensive Skill","type":"select","required":true,"code":"DefSkill","formResetBehavior":"reset","defaultValue":"No Defense","choices":{"No Defense":"No Defense","WAY Below Average":"WAY Below Average","Below Average":"Below Average","Average":"Average","Above Average":"Above Average","WAY Above Average":"WAY Above Average"}},{"title":"Being Defended?","type":"boolean","required":false,"code":"CFPDT","formResetBehavior":"reset","defaultValue":false},{"title":"Comments!","type":"text","required":true,"code":"comments","formResetBehavior":"reset","defaultValue":"","min":0,"max":300}]}]} \ No newline at end of file +{ + "title": "QRScout", + "page_title": "Rebuilt", + "delimiter": "\t", + "teamNumber": 1100, + "theme": { + "light": { + "background": "0 0% 100%", + "foreground": "0 0% 3.9%", + "card": "0 0% 100%", + "card_foreground": "0 0% 3.9%", + "popover": "0 0% 100%", + "popover_foreground": "0 0% 3.9%", + "primary": "354.44 71.3% 47.9%", + "primary_foreground": "0 85.7% 97.3%", + "secondary": "0 0% 96.1%", + "secondary_foreground": "0 0% 9%", + "muted": "0 0% 96.1%", + "muted_foreground": "0 0% 45.1%", + "accent": "0 0% 96.1%", + "accent_foreground": "0 0% 9%", + "destructive": "0 84.2% 60.2%", + "destructive_foreground": "0 0% 98%", + "border": "0 0% 89.8%", + "input": "0 0% 89.8%", + "ring": "354.44 71.3% 47.9%", + "chart_1": "12 76% 61%", + "chart_2": "173 58% 39%", + "chart_3": "197 37% 24%", + "chart_4": "43 74% 66%", + "chart_5": "27 87% 67%" + }, + "dark": { + "background": "0 0% 3.9%", + "foreground": "0 0% 98%", + "card": "0 0% 3.9%", + "card_foreground": "0 0% 98%", + "popover": "0 0% 3.9%", + "popover_foreground": "0 0% 98%", + "primary": "354.44 71.3% 47.9%", + "primary_foreground": "0 85.7% 97.3%", + "secondary": "0 0% 14.9%", + "secondary_foreground": "0 0% 98%", + "muted": "0 0% 14.9%", + "muted_foreground": "0 0% 63.9%", + "accent": "0 0% 14.9%", + "accent_foreground": "0 0% 98%", + "destructive": "0 62.8% 30.6%", + "destructive_foreground": "0 0% 98%", + "border": "0 0% 14.9%", + "input": "0 0% 14.9%", + "ring": "354.44 71.3% 47.9%", + "chart_1": "220 70% 50%", + "chart_2": "160 60% 45%", + "chart_3": "30 80% 55%", + "chart_4": "280 65% 60%", + "chart_5": "340 75% 55%" + } + }, + "sections": [ + { + "name": "Prematch", + "fields": [ + { + "title": "Scouter Initials", + "description": "Enter the initials of the scouter.", + "type": "text", + "required": true, + "code": "scouter", + "formResetBehavior": "preserve", + "defaultValue": "" + }, + { + "title": "Match Number", + "description": "Enter the match number.", + "type": "number", + "required": true, + "code": "matchNumber", + "formResetBehavior": "increment", + "defaultValue": 1 + }, + { + "title": "Team Number", + "description": "The team number of the robot you're scouting.", + "type": "number", + "required": true, + "code": "teamNumber", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "max": 99999 + }, + { + "title": "Prediction", + "description": "Which alliance do you think will win?", + "type": "select", + "required": false, + "code": "alliancePrediction", + "formResetBehavior": "reset", + "defaultValue": "", + "choices": { + "Red": "Red", + "Blue": "Blue" + } + }, + { + "title": "No Show", + "description": "Check if the robot did not show up for the match.", + "type": "boolean", + "required": false, + "code": "noShow", + "formResetBehavior": "reset", + "defaultValue": false + } + ] + }, + { + "name": "Auto", + "color": "#3b82f6", + "fields": [ + { + "title": "HUB (+1)", + "description": "HUB1", + "type": "counter", + "required": false, + "code": "HAuto1", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 1 + }, + { + "title": "HUB (+3)", + "description": "HUB3", + "type": "counter", + "required": false, + "code": "HAuto3", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 3 + }, + { + "title": "HUB (+5)", + "description": "HUB5", + "type": "counter", + "required": false, + "code": "HAuto5", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 5 + }, + { + "title": "FERRIED (+1)", + "description": "FER1", + "type": "counter", + "required": false, + "code": "FAuto1", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 1 + }, + { + "title": "FERRIED (+3)", + "description": "FER3", + "type": "counter", + "required": false, + "code": "FAuto3", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 3 + }, + { + "title": "FERRIED (+5)", + "description": "FER5", + "type": "counter", + "required": false, + "code": "FAuto5", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 5 + }, + { + "title": "Auto Climb Position", + "type": "select", + "required": true, + "code": "climbauto", + "formResetBehavior": "reset", + "defaultValue": "Nothing", + "choices": { + "No Climb": "No Climb", + "Left": "Left", + "Middle": "Middle", + "Right": "Right" + } + }, + { + "title": "Climbed Successfully?", + "description": "Did the robot get off the gound?", + "type": "boolean", + "required": false, + "code": "boolClimb", + "formResetBehavior": "reset", + "defaultValue": false + }, + { + "title": "Accuracy?", + "type": "select", + "required": true, + "code": "Accuracy?A", + "formResetBehavior": "reset", + "defaultValue": "", + "choices": { + "Didn't shoot": "Didn't shoot", + "1.00": "100%", + ".945": "90-99%", + ".8": "70-90%", + ".6": "50-70%", + ".5": "50%", + ".4": "30-50%", + ".2": "10-30%", + ".05": "1-10%", + "0.0": "0%" + } + } + ] + }, + { + "name": "Teleop", + "color": "#bf9000", + "fields": [ + { + "title": "HUB (+1)", + "description": "HUB1", + "type": "counter", + "required": false, + "code": "HTele1", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 1 + }, + { + "title": "HUB (+3)", + "description": "HUB3", + "type": "counter", + "required": false, + "code": "HTele3", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 3 + }, + { + "title": "HUB (+5)", + "description": "HUB5", + "type": "counter", + "required": false, + "code": "HTele5", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 5 + }, + { + "title": "FERRIED (+1)", + "description": "FER1", + "type": "counter", + "required": false, + "code": "FTele1", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 1 + }, + { + "title": "FERRIED (+3)", + "description": "FER3", + "type": "counter", + "required": false, + "code": "FTele3", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 3 + }, + { + "title": "FERRIED (+5)", + "description": "FER5", + "type": "counter", + "required": false, + "code": "FTele5", + "formResetBehavior": "reset", + "defaultValue": 0, + "min": 0, + "step": 5 + }, + { + "title": "Accuracy?", + "type": "select", + "required": true, + "code": "Accuracy?C", + "formResetBehavior": "reset", + "defaultValue": "", + "choices": { + "Didn't shoot": "Didn't shoot", + "1.00": "100%", + ".945": "90-99%", + ".8": "70-90%", + ".6": "50-70%", + ".5": "50%", + ".4": "30-50%", + ".2": "10-30%", + ".05": "1-10%", + "0.0": "0%" + } + } + ] + }, + { + "name": "Postmatch", + "color": "#2a8525", + "fields": [ + { + "title": "Climb", + "type": "select", + "required": true, + "code": "endPos", + "formResetBehavior": "reset", + "defaultValue": "No Climb", + "choices": { + "No Climb": "No Climb", + "Failed Climb": "Failed Climb", + "L1": "L1 Climb", + "L2": "L2 Climb", + "L3": "L3 Climb" + } + }, + { + "title": "Crossed Trench?", + "type": "boolean", + "required": false, + "code": "CFPDT", + "formResetBehavior": "reset", + "defaultValue": false + }, + { + "title": "Crossed Bump?", + "type": "select", + "required": true, + "code": "Bump?C", + "formResetBehavior": "reset", + "defaultValue": "Did Not Cross", + "choices": { + "Did Not Cross": "Did Not Cross", + "Got stuck": "Got stuck", + "Difficulties": "Had Some Difficulties", + "Crossed Well": "Crossed Well" + } + }, + { + "title": "Issues during Match", + "type": "select", + "required": true, + "code": "IdM", + "formResetBehavior": "reset", + "defaultValue": "No Problems", + "choices": { + "No Problems": "No Problems", + "Died and Stayed Dead": "Died and Stayed Dead", + "Died and Came Back": "Died and Came Back", + "Fell Over": "Fell Over", + "Brown Out": "Brown Out", + "Other Mechanical Issue": "Other Mechanical Issue" + } + }, + { + "title": "Defensive Skill", + "type": "select", + "required": true, + "code": "DefSkill", + "formResetBehavior": "reset", + "defaultValue": "No Defense", + "choices": { + "No Defense": "No Defense", + "WAY Below Average": "WAY Below Average", + "Below Average": "Below Average", + "Average": "Average", + "Above Average": "Above Average", + "WAY Above Average": "WAY Above Average" + } + }, + { + "title": "Being Defended?", + "type": "boolean", + "required": false, + "code": "CFPDT", + "formResetBehavior": "reset", + "defaultValue": false + }, + { + "title": "Comments!", + "type": "text", + "required": true, + "code": "comments", + "formResetBehavior": "reset", + "defaultValue": "", + "min": 0, + "max": 300 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/components/QR/QRModal.tsx b/src/components/QR/QRModal.tsx index 448a45789..f24f96da5 100644 --- a/src/components/QR/QRModal.tsx +++ b/src/components/QR/QRModal.tsx @@ -1,6 +1,6 @@ -import { Copy, QrCode } from 'lucide-react'; +import { Camera, Copy, Download, QrCode } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; -import { useMemo } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { getFieldValue, useQRScoutState } from '../../store/store'; import { Button } from '../ui/button'; import { @@ -19,7 +19,11 @@ export interface QRModalProps { export function QRModal(props: QRModalProps) { const fieldValues = useQRScoutState(state => state.fieldValues); const formData = useQRScoutState(state => state.formData); - const title = `${getFieldValue('robot')} - M${getFieldValue( + const qrContainerRef = useRef(null); + const [isSavingScreenshot, setIsSavingScreenshot] = useState(false); + const [isCopyingScreenshot, setIsCopyingScreenshot] = useState(false); + const robotOrTeam = getFieldValue('robot') || getFieldValue('teamNumber'); + const title = `${robotOrTeam} - M${getFieldValue( 'matchNumber', )}`.toUpperCase(); @@ -29,6 +33,126 @@ export function QRModal(props: QRModalProps) { ); //Two seperate values are required- qrCodePreview is what is shown to the user beneath the QR code, qrCodeData is the actual data. + const generateCanvas = async () => { + const svg = qrContainerRef.current?.querySelector('svg'); + if (!svg) { + return null; + } + + const serialized = new XMLSerializer().serializeToString(svg); + const svgString = serialized.includes('xmlns="http://www.w3.org/2000/svg"') + ? serialized + : serialized.replace( + '((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Unable to load QR image')); + img.src = svgUrl; + }); + + const qrSize = Number(svg.getAttribute('width') ?? 256); + const padding = 32; + const titleFontSize = 32; + const gap = 24; + + const measureCanvas = document.createElement('canvas'); + const measureCtx = measureCanvas.getContext('2d'); + if (!measureCtx) { + URL.revokeObjectURL(svgUrl); + return null; + } + + measureCtx.font = `700 ${titleFontSize}px sans-serif`; + const titleWidth = Math.ceil(measureCtx.measureText(title).width); + + const canvasWidth = Math.max(qrSize + padding * 2, titleWidth + padding * 2); + const canvasHeight = padding + titleFontSize + gap + qrSize + padding; + + const canvas = document.createElement('canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(svgUrl); + return null; + } + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + + ctx.fillStyle = '#111827'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.font = `700 ${titleFontSize}px sans-serif`; + ctx.fillText(title, canvasWidth / 2, padding); + + ctx.drawImage( + qrImage, + Math.floor((canvasWidth - qrSize) / 2), + padding + titleFontSize + gap, + qrSize, + qrSize, + ); + + URL.revokeObjectURL(svgUrl); + return canvas; + }; + + const handleSaveScreenshot = async () => { + setIsSavingScreenshot(true); + + try { + const canvas = await generateCanvas(); + if (!canvas) return; + + const downloadLink = document.createElement('a'); + downloadLink.download = `${title.replace(/[^a-z0-9]+/gi, '_')}_qr.png`; + downloadLink.href = canvas.toDataURL('image/png'); + downloadLink.click(); + } finally { + setIsSavingScreenshot(false); + } + }; + + const handleCopyScreenshot = async () => { + setIsCopyingScreenshot(true); + + try { + const canvas = await generateCanvas(); + if (!canvas) return; + + await new Promise((resolve, reject) => { + canvas.toBlob(async (blob) => { + if (!blob) { + reject(new Error('Failed to create image blob')); + return; + } + + try { + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }) + ]); + resolve(); + } catch (err) { + reject(err); + } + }, 'image/png'); + }); + } finally { + setIsCopyingScreenshot(false); + } + }; + return ( @@ -38,16 +162,32 @@ export function QRModal(props: QRModalProps) { - - {title} - -
+
+ + {title} +
+
+
+ +