diff --git a/src/app.tsx b/src/app.tsx index 66d23e2..58bdc2a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,176 +1,34 @@ -import { useTheme } from 'next-themes'; -import { useMemo, useState } from 'preact/hooks'; -import { Logo } from './components/Logo'; -import QRModal from './components/QRModal'; -import Section from './components/Section'; -import Button, { Variant } from './components/core/Button'; -import { - getQRCodeData, - resetSections, - resetToDefaultConfig, - uploadConfig, - useQRScoutState, -} from './store/store'; +import { useState } from 'preact/hooks'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { QRModal } from './components/QR'; +import { Sections } from './components/Sections'; +import { CommitAndResetSection } from './components/Sections/CommitAndResetSection/CommitAndResetSection'; +import { ConfigSection } from './components/Sections/ConfigSection'; +import { useQRScoutState } from './store/store'; export function App() { - const { theme, setTheme } = useTheme(); - const formData = useQRScoutState(state => state.formData); - const [showQR, setShowQR] = useState(false); - const missingRequiredFields = useMemo(() => { - return formData.sections - .map(s => s.fields) - .flat() - .filter( - f => - f.required && - (f.value === null || f.value === undefined || f.value === ``), - ); - }, [formData]); - - function getFieldValue(code: string): any { - return formData.sections - .map(s => s.fields) - .flat() - .find(f => f.code === code)?.value; - } - - function download(filename: string, text: string) { - var element = document.createElement('a'); - element.setAttribute( - 'href', - 'data:text/plain;charset=utf-8,' + encodeURIComponent(text), - ); - element.setAttribute('download', filename); - - element.style.display = 'none'; - document.body.appendChild(element); - - element.click(); - - document.body.removeChild(element); - } - - function downloadConfig() { - const configDownload = { ...formData }; - - configDownload.sections.forEach(s => - s.fields.forEach(f => (f.value = undefined)), - ); - download('QRScout_config.json', JSON.stringify(configDownload)); - } - return (
- - QRScout|{formData.title} - - - +

{formData.page_title}

- setShowQR(false)} - /> + setShowQR(false)} />
- {formData.sections.map(section => { - return
; - })} - -
- - -
-
- - - -
-
- Theme -
- -
- - -
+ + setShowQR(true)} /> +
-
-
- -
-
+
); } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..a26ffe1 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,11 @@ +import { Logo } from './Logo'; + +export function Footer() { + return ( + + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..ce68501 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,11 @@ +import { useQRScoutState } from '../store/store'; + +export function Header() { + const title = useQRScoutState(state => state.formData.title); + return ( + + QRScout|{title} + + + ); +} diff --git a/src/components/QR/CloseButton.tsx b/src/components/QR/CloseButton.tsx new file mode 100644 index 0000000..7efe6b7 --- /dev/null +++ b/src/components/QR/CloseButton.tsx @@ -0,0 +1,28 @@ +export type CloseButtonProps = { + onClick: () => void; +}; + +export function CloseButton(props: CloseButtonProps) { + return ( + + ); +} diff --git a/src/components/QR/CopyButton.tsx b/src/components/QR/CopyButton.tsx new file mode 100644 index 0000000..3d480af --- /dev/null +++ b/src/components/QR/CopyButton.tsx @@ -0,0 +1,26 @@ +export type CopyButtonProps = { + onCopy: () => void; + className?: string; +}; + +export function CopyButton(props: CopyButtonProps) { + return ( +
+ + + + + +
+ ); +} diff --git a/src/components/QR/PreviewText.tsx b/src/components/QR/PreviewText.tsx new file mode 100644 index 0000000..c20897f --- /dev/null +++ b/src/components/QR/PreviewText.tsx @@ -0,0 +1,27 @@ +import { CopyButton } from './CopyButton'; + +export type PreviewTextProps = { + data: string; +}; +export function PreviewText(props: PreviewTextProps) { + const chunks = props.data.split('\t'); + return ( +
+
+

+ {chunks.map((c, i) => ( + <> + {c} + + + {i !== chunks.length - 1 ? '|' : ' ↵'} + + + ))} +

+
+ + navigator.clipboard.writeText(props.data)} /> +
+ ); +} diff --git a/src/components/QR/QRModal.tsx b/src/components/QR/QRModal.tsx new file mode 100644 index 0000000..156fbdb --- /dev/null +++ b/src/components/QR/QRModal.tsx @@ -0,0 +1,56 @@ +import { useMemo, useRef } from 'preact/hooks'; +import QRCode from 'qrcode.react'; +import { useOnClickOutside } from '../../hooks/useOnClickOutside'; +import { getFieldValue, useQRScoutState } from '../../store/store'; +import { Config } from '../inputs/BaseInputProps'; +import { CloseButton } from './CloseButton'; +import { PreviewText } from './PreviewText'; + +export interface QRModalProps { + show: boolean; + onDismiss: () => void; +} + +export function getQRCodeData(formData: Config): string { + return formData.sections + .map(s => s.fields) + .flat() + .map(v => `${v.value}`.replace(/\n/g, ' ')) + .join('\t'); +} + +export function QRModal(props: QRModalProps) { + const modalRef = useRef(null); + const formData = useQRScoutState(state => state.formData); + useOnClickOutside(modalRef, props.onDismiss); + + const title = `${getFieldValue('robot')} - M${getFieldValue( + 'matchNumber', + )}`.toUpperCase(); + + const qrCodeData = useMemo(() => getQRCodeData(formData), [formData]); + return ( + <> + {props.show && ( + <> +
+
+
+

{title}

+ + +
+ +
+
+ + )} + + ); +} diff --git a/src/components/QR/index.ts b/src/components/QR/index.ts new file mode 100644 index 0000000..bfdaa03 --- /dev/null +++ b/src/components/QR/index.ts @@ -0,0 +1 @@ +export * from './QRModal'; diff --git a/src/components/QRModal.tsx b/src/components/QRModal.tsx deleted file mode 100644 index 52dc66e..0000000 --- a/src/components/QRModal.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import QRCode from 'qrcode.react'; - -export interface QRModalProps { - show: boolean; - title: string; - data: string; - onDismiss: () => void; -} - -export default function QRModal(props: QRModalProps) { - return ( - <> - {props.show && ( - <> -
-
-
-

{props.title.toUpperCase()}

- -
-
- navigator.clipboard.writeText(props.data + '\n') - } - > - - {' '} - {' '} - - -
- -
-
-
- - )} - - ); -} diff --git a/src/components/Sections/CommitAndResetSection/CommitAndResetSection.tsx b/src/components/Sections/CommitAndResetSection/CommitAndResetSection.tsx new file mode 100644 index 0000000..d7cd8bf --- /dev/null +++ b/src/components/Sections/CommitAndResetSection/CommitAndResetSection.tsx @@ -0,0 +1,34 @@ +import { useMemo } from 'preact/hooks'; +import { useQRScoutState } from '../../../store/store'; +import { CommitButton } from './CommitButton'; +import { ResetButton } from './ResetButton'; + +export type CommitAndResetSectionProps = { + onCommit: () => void; +}; + +export function CommitAndResetSection({ + onCommit, +}: CommitAndResetSectionProps) { + const formData = useQRScoutState(state => state.formData); + const missingRequiredFields = useMemo(() => { + return formData.sections + .map(s => s.fields) + .flat() + .filter( + f => + f.required && + (f.value === null || f.value === undefined || f.value === ``), + ); + }, [formData]); + + return ( +
+ 0} + onClick={onCommit} + /> + +
+ ); +} diff --git a/src/components/Sections/CommitAndResetSection/CommitButton.tsx b/src/components/Sections/CommitAndResetSection/CommitButton.tsx new file mode 100644 index 0000000..4767218 --- /dev/null +++ b/src/components/Sections/CommitAndResetSection/CommitButton.tsx @@ -0,0 +1,17 @@ +export type CommitButtonProps = { + onClick: () => void; + disabled: boolean; +}; + +export function CommitButton(props: CommitButtonProps) { + return ( + + ); +} diff --git a/src/components/Sections/CommitAndResetSection/ResetButton.tsx b/src/components/Sections/CommitAndResetSection/ResetButton.tsx new file mode 100644 index 0000000..35d0f98 --- /dev/null +++ b/src/components/Sections/CommitAndResetSection/ResetButton.tsx @@ -0,0 +1,18 @@ +import { resetSections } from '../../../store/store'; + +export type ResetButtonProps = { + disabled?: boolean; +}; + +export function ResetButton(props: ResetButtonProps) { + return ( + + ); +} diff --git a/src/components/Sections/CommitAndResetSection/index.ts b/src/components/Sections/CommitAndResetSection/index.ts new file mode 100644 index 0000000..7f3485a --- /dev/null +++ b/src/components/Sections/CommitAndResetSection/index.ts @@ -0,0 +1 @@ +export * from './CommitAndResetSection'; diff --git a/src/components/Sections/ConfigSection/ConfigSection.tsx b/src/components/Sections/ConfigSection/ConfigSection.tsx new file mode 100644 index 0000000..506f65f --- /dev/null +++ b/src/components/Sections/ConfigSection/ConfigSection.tsx @@ -0,0 +1,84 @@ +import { + resetToDefaultConfig, + uploadConfig, + useQRScoutState, +} from '../../../store/store'; +import Button, { Variant } from '../../core/Button'; +import { Config } from '../../inputs/BaseInputProps'; +import { ThemeSelector } from './ThemeSelector'; + +/** + * Download a text file + * @param filename The name of the file + * @param text The text to put in the file + */ +function download(filename: string, text: string) { + var element = document.createElement('a'); + element.setAttribute( + 'href', + 'data:text/plain;charset=utf-8,' + encodeURIComponent(text), + ); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +/** + * Download the current form data as a json file + * @param formData The form data to download + */ +function downloadConfig(formData: Config) { + const configDownload = { ...formData }; + + configDownload.sections.forEach(s => + s.fields.forEach(f => (f.value = undefined)), + ); + download('QRScout_config.json', JSON.stringify(configDownload)); +} + +export function ConfigSection() { + const formData = useQRScoutState(state => state.formData); + return ( +
+ + + + + + +
+ ); +} diff --git a/src/components/Sections/ConfigSection/ThemeSelector.tsx b/src/components/Sections/ConfigSection/ThemeSelector.tsx new file mode 100644 index 0000000..ea47266 --- /dev/null +++ b/src/components/Sections/ConfigSection/ThemeSelector.tsx @@ -0,0 +1,27 @@ +import { useTheme } from 'next-themes'; + +export function ThemeSelector() { + const { theme, setTheme } = useTheme(); + return ( +
+
Theme
+ +
+ ); +} diff --git a/src/components/Sections/ConfigSection/index.ts b/src/components/Sections/ConfigSection/index.ts new file mode 100644 index 0000000..f30819e --- /dev/null +++ b/src/components/Sections/ConfigSection/index.ts @@ -0,0 +1 @@ +export * from './ConfigSection'; diff --git a/src/components/Section.tsx b/src/components/Sections/FormSection.tsx similarity index 91% rename from src/components/Section.tsx rename to src/components/Sections/FormSection.tsx index 50db6b3..e8bc634 100644 --- a/src/components/Section.tsx +++ b/src/components/Sections/FormSection.tsx @@ -1,13 +1,13 @@ +import { useQRScoutState } from '../../store/store'; import { InputProps } from '../inputs/BaseInputProps'; import ConfigurableInput from '../inputs/ConfigurableInput'; import InputCard from '../inputs/InputCard'; -import { useQRScoutState } from '../store/store'; interface SectionProps { name: string; } -export default function Section(props: SectionProps) { +export default function FormSection(props: SectionProps) { const formData = useQRScoutState(state => state.formData); const inputs = formData.sections.find(s => s.name === props.name)?.fields; return ( diff --git a/src/components/Sections/Sections.tsx b/src/components/Sections/Sections.tsx new file mode 100644 index 0000000..64a07c1 --- /dev/null +++ b/src/components/Sections/Sections.tsx @@ -0,0 +1,13 @@ +import { useQRScoutState } from '../../store/store'; +import FormSection from './FormSection'; + +export function Sections() { + const formData = useQRScoutState(state => state.formData); + return ( + <> + {formData.sections.map(section => { + return ; + })} + + ); +} diff --git a/src/components/Sections/index.ts b/src/components/Sections/index.ts new file mode 100644 index 0000000..631ad69 --- /dev/null +++ b/src/components/Sections/index.ts @@ -0,0 +1 @@ +export * from './Sections'; diff --git a/src/inputs/BaseInputProps.ts b/src/components/inputs/BaseInputProps.ts similarity index 100% rename from src/inputs/BaseInputProps.ts rename to src/components/inputs/BaseInputProps.ts diff --git a/src/inputs/CheckboxInput.tsx b/src/components/inputs/CheckboxInput.tsx similarity index 100% rename from src/inputs/CheckboxInput.tsx rename to src/components/inputs/CheckboxInput.tsx diff --git a/src/inputs/ConfigurableInput.tsx b/src/components/inputs/ConfigurableInput.tsx similarity index 99% rename from src/inputs/ConfigurableInput.tsx rename to src/components/inputs/ConfigurableInput.tsx index e0dbc70..dc31411 100644 --- a/src/inputs/ConfigurableInput.tsx +++ b/src/components/inputs/ConfigurableInput.tsx @@ -1,4 +1,4 @@ -import { inputSelector, updateValue, useQRScoutState } from '../store/store'; +import { inputSelector, updateValue, useQRScoutState } from '../../store/store'; import Checkbox from './CheckboxInput'; import CounterInput from './CounterInput'; import NumberInput from './NumberInput'; diff --git a/src/inputs/CounterInput.tsx b/src/components/inputs/CounterInput.tsx similarity index 100% rename from src/inputs/CounterInput.tsx rename to src/components/inputs/CounterInput.tsx diff --git a/src/inputs/InputCard.tsx b/src/components/inputs/InputCard.tsx similarity index 100% rename from src/inputs/InputCard.tsx rename to src/components/inputs/InputCard.tsx diff --git a/src/inputs/NumberInput.tsx b/src/components/inputs/NumberInput.tsx similarity index 100% rename from src/inputs/NumberInput.tsx rename to src/components/inputs/NumberInput.tsx diff --git a/src/inputs/RangeInput.tsx b/src/components/inputs/RangeInput.tsx similarity index 100% rename from src/inputs/RangeInput.tsx rename to src/components/inputs/RangeInput.tsx diff --git a/src/inputs/SelectInput.tsx b/src/components/inputs/SelectInput.tsx similarity index 100% rename from src/inputs/SelectInput.tsx rename to src/components/inputs/SelectInput.tsx diff --git a/src/inputs/StringInput.tsx b/src/components/inputs/StringInput.tsx similarity index 92% rename from src/inputs/StringInput.tsx rename to src/components/inputs/StringInput.tsx index 55be539..aefffa8 100644 --- a/src/inputs/StringInput.tsx +++ b/src/components/inputs/StringInput.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { inputSelector, useQRScoutState } from '../store/store'; +import { inputSelector, useQRScoutState } from '../../store/store'; import BaseInputProps from './BaseInputProps'; export interface StringInputProps extends BaseInputProps { diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..84bc075 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useOnClickOutside'; diff --git a/src/hooks/useOnClickOutside.tsx b/src/hooks/useOnClickOutside.tsx new file mode 100644 index 0000000..e6f99e9 --- /dev/null +++ b/src/hooks/useOnClickOutside.tsx @@ -0,0 +1,35 @@ +import React, { useEffect, useRef } from 'react'; + +export const useOnClickOutside = ( + ref: React.MutableRefObject, + callback: VoidFunction, +): void => { + const savedCallback = useRef(); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + const listener = (event: MouseEvent | TouchEvent): void => { + if ( + ref && + ref.current && + event.target && + !ref.current.contains(event.target as Node) + ) { + if (savedCallback.current) { + savedCallback.current(); + } + } + }; + + document.addEventListener('mousedown', listener); + document.addEventListener('touchstart', listener); + + return () => { + document.removeEventListener('mousedown', listener); + document.removeEventListener('touchstart', listener); + }; + }, [ref]); +}; diff --git a/src/store/store.ts b/src/store/store.ts index 9105b91..0d2bb4e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,7 +1,7 @@ import { produce } from 'immer'; import { ChangeEvent } from 'react'; import configJson from '../../config/2024/config.json'; -import { Config } from '../inputs/BaseInputProps'; +import { Config } from '../components/inputs/BaseInputProps'; import { createStore } from './createStore'; function buildConfig(c: Config) { @@ -91,11 +91,10 @@ export const inputSelector = ?.fields.find(f => f.code === code); }; -export function getQRCodeData(): string { +export function getFieldValue(code: string) { return useQRScoutState .getState() .formData.sections.map(s => s.fields) .flat() - .map(v => `${v.value}`.replace(/\n/g, ' ')) - .join('\t'); + .find(f => f.code === code)?.value; }