// Dependencies import { cloudflareImageUrl, measurementAsMm, measurementAsUnits, distanceAsMm, } from '@freesewing/utils' import { collection } from '@freesewing/collection' import { measurements as measurementsTranslations } from '@freesewing/i18n' // Context import { ModalContext } from '@freesewing/react/context/Modal' import { LoadingStatusContext } from '@freesewing/react/context/LoadingStatus' // Hooks import React, { useState, useCallback, useContext } from 'react' import { useDropzone } from 'react-dropzone' import { useBackend } from '@freesewing/react/hooks/useBackend' // Components import { Link as WebLink } from '@freesewing/react/components/Link' import { TrashIcon, ResetIcon, UploadIcon, HelpIcon } from '@freesewing/react/components/Icon' import { ModalWrapper } from '@freesewing/react/components/Modal' import { isDegreeMeasurement } from '@freesewing/config' import { Tabs, Tab } from '@freesewing/react/components/Tab' import Markdown from 'react-markdown' /* * Helper component to display a tab heading */ export const _Tab = ({ id, // The tab ID label, // A label for the tab, if not set we'll use the ID activeTab, // Which tab (id) is active setActiveTab, // Method to set the active tab }) => ( <button className={`tw:text-lg tw:font-bold tw:capitalize tw:daisy-tab tw:daisy-tab-bordered tw:grow ${activeTab === id ? 'tw:daisy-tab-active' : ''}`} onClick={() => setActiveTab(id)} > {label ? label : id} </button> ) /** * A helper component to render the help link in formcontrol * * @param {string|function| help - The help href of onClick method * @param {React.component} Link - An optional framework-specific link component */ const HelpLink = ({ help, Link = false }) => { if (!Link) Link = WebLink if (typeof help === 'function') return ( <button onClick={() => help} title="Show help"> <HelpIcon className="tw:w-5 tw:h-5" /> </button> ) if (typeof help === 'string') return ( <Link href={help} target="_BLANK" rel="nofollow" title="Show help"> <HelpIcon className="tw:w-5 tw:h-5" /> </Link> ) return null } /* * Helper component to wrap a form control with a label */ export const FormControl = ({ label, // the (top-left) label children, // Children to go inside the form control labelTR = false, // Optional top-right label labelBL = false, // Optional bottom-left label labelBR = false, // Optional bottom-right label forId = false, // ID of the for element we are wrapping help = false, // An optional URL/method to link/show help/docs Link = false, // An optional framework-specific link components }) => { if (labelBR && !labelBL) labelBL = <span></span> const topLabelChildren = ( <> {label ? ( <span className="tw:daisy-label-text tw:text-sm tw:lg:text-base tw:font-bold tw:mb-1 tw:text-inherit tw:inline-flex tw:items-center tw:gap-1"> {label} <HelpLink {...{ help, Link }} /> </span> ) : ( <span> <HelpLink {...{ help, Link }} /> </span> )} {labelTR ? <span className="tw:daisy-label-text-alt tw:-mb-1">{labelTR}</span> : null} </> ) const bottomLabelChildren = ( <div className="tw:flex tw:flex-row tw:justify-between tw:w-full tw:items-start"> {labelBL ? <span className="tw:daisy-label-text-alt">{labelBL}</span> : null} {labelBR ? <span className="tw:daisy-label-text-alt">{labelBR}</span> : null} </div> ) return ( <div className="tw:daisy-form-control tw:w-full tw:mt-2"> {forId ? ( <label className="tw:daisy-label tw:pb-0" htmlFor={forId}> {topLabelChildren} </label> ) : label ? ( <div className="tw:daisy-label tw:pb-0">{topLabelChildren}</div> ) : null} {children} {labelBL || labelBR ? ( forId ? ( <label className="tw:daisy-label tw:w-full" htmlFor={forId}> {bottomLabelChildren} </label> ) : ( <div className="tw:daisy-label tw:w-full">{bottomLabelChildren}</div> ) ) : null} </div> ) } /* * Helper method to wrap content in a button */ export const ButtonFrame = ({ children, // Children of the button onClick, // onClick handler active, // Whether or not to render the button as active/selected accordion = false, // Set this to true to not set a background color when active dense = false, // Use less padding }) => ( <button className={` tw:daisy-btn tw:daisy-btn-ghost tw:h-fit tw:w-full ${dense ? 'tw:mt-1 tw:daisy-btn-sm tw:font-light' : 'tw:mt-2 tw:py-4 tw:h-auto tw:content-start'} tw:border-2 tw:border-secondary tw:text-left tw:bg-secondary/20 ${accordion ? 'tw:hover:bg-transparent' : 'tw:hover:bg-secondary/10'} tw:hover:border-secondary tw:hover:border-solid tw:hover:border-2 ${active ? 'tw:border-solid' : 'tw:border-dotted'} ${active && !accordion ? 'tw:bg-secondary' : 'tw:bg-transparent'} `} onClick={onClick} > {children} </button> ) /* * Input for integers */ export const NumberInput = ({ label, // Label to use update, // onChange handler valid, // Method that should return whether the value is valid or not current, // The current value original, // The original value placeholder, // The placeholder text id = '', // An id to tie the input to the label labelBL = false, // Bottom-Left label labelBR = false, // Bottom-Right label max = 0, min = 220, step = 1, }) => ( <FormControl {...{ label, labelBL, labelBR }} forId={id}> <input id={id} type="text" inputMode="decimal" placeholder={placeholder} value={current} onChange={(evt) => update(evt.target.value)} className={`tw:daisy-input tw:w-full tw:daisy-input-bordered ${ current === original ? 'tw:daisy-input-secondary' : valid(current) ? 'tw:daisy-input-success' : 'tw:daisy-input-error' }`} {...{ max, min, step }} /> </FormControl> ) /* * Input for strings */ export const StringInput = ({ label, // Label to use update, // onChange handler valid, // Method that should return whether the value is valid or not current, // The current value original, // The original value placeholder, // The placeholder text id = '', // An id to tie the input to the label labelBL = false, // Bottom-Left label labelBR = false, // Bottom-Right label }) => ( <FormControl {...{ label, labelBL, labelBR }} forId={id}> <input id={id} type="text" placeholder={placeholder} value={current} onChange={(evt) => update(evt.target.value)} className={`tw:daisy-input tw:w-full tw:daisy-input-bordered tw:text-current ${ current === original ? 'tw:daisy-input-secondary' : valid(current) ? 'tw:daisy-input-success' : 'tw:daisy-input-error' }`} /> </FormControl> ) /* * Input for MFA code */ export const MfaInput = ({ update, // onChange handler current, // The current value id = 'mfa', // An id to tie the input to the label }) => { return ( <StringInput label="MFA Code" valid={(val) => val.length > 4} {...{ update, current, id }} placeholder="MFA Code" /> ) } /* * Input for passwords */ export const PasswordInput = ({ label, // Label to use update, // onChange handler valid, // Method that should return whether the value is valid or not current, // The current value placeholder = '¯\\_(ツ)_/¯', // The placeholder text id = '', // An id to tie the input to the label onKeyDown = false, // Optionall capture certain keys (like enter) }) => { const [reveal, setReveal] = useState(false) const extraProps = onKeyDown ? { onKeyDown } : {} return ( <FormControl label={label} forId={id} labelBR={ <button className="tw:btn tw:btn-primary tw:btn-ghost tw:btn-xs tw:-mt-2" onClick={() => setReveal(!reveal)} > {reveal ? 'Hide Password' : 'Reveal Password'} </button> } > <input id={id} type={reveal ? 'text' : 'password'} placeholder={placeholder} value={current} onChange={(evt) => update(evt.target.value)} className={`tw:daisy-input tw:w-full tw:daisy-input-bordered ${ valid(current) ? 'input-success' : 'input-error' }`} {...extraProps} /> </FormControl> ) } /* * Input for email addresses */ export const EmailInput = ({ label, // Label to use update, // onChange handler valid, // Method that should return whether the value is valid or not current, // The current value original, // The original value placeholder, // The placeholder text id = '', // An id to tie the input to the label labelTR = false, // Top-Right label labelBL = false, // Bottom-Left label labelBR = false, // Bottom-Right label }) => ( <FormControl {...{ label, labelTR, labelBL, labelBR }} forId={id}> <input id={id} type="email" placeholder={placeholder} value={current} onChange={(evt) => update(evt.target.value)} className={`tw:daisy-input tw:w-full tw:daisy-input-bordered ${ current === original ? 'tw:daisy-input-secondary' : valid(current) ? 'tw:daisy-input-success' : 'tw:daisy-input-error' }`} /> </FormControl> ) /* * Input for designs */ export const DesignInput = ({ label, // Label to use update, // onChange handler current, // The current value firstOption = null, // Any first option to add in addition to designs id = '', // An id to tie the input to the label }) => { return ( <FormControl label={label} forId={id}> <select id={id} className="tw:daisy-select tw:daisy-select-bordered tw:w-full" onChange={(evt) => update(evt.target.value)} value={current} > {firstOption} {collection.map((design) => ( <option key={design} value={design}> {design} </option> ))} </select> </FormControl> ) } /* * Input for an image */ export const ImageInput = ({ label, // The label update, // The onChange handler current, // The current value original, // The original value active = false, // Whether or not to upload images imgType = 'showcase', // The image type imgSubid, // The image sub-id imgSlug, // The image slug or other unique identifier to use in the image ID id = '', // An id to tie the input to the label }) => { const backend = useBackend() const { setLoadingStatus } = useContext(LoadingStatusContext) const [url, setUrl] = useState(false) const [uploadedId, setUploadedId] = useState(false) const upload = async (img, fromUrl = false) => { setLoadingStatus([true, 'uploadingImage']) const data = { type: imgType, subId: imgSubid, slug: imgSlug, } if (fromUrl) data.url = img else data.img = img const result = await backend.uploadAnonImage(data) setLoadingStatus([true, 'allDone', true, true]) if (result.success) { update(result.data.imgId) setUploadedId(result.data.imgId) } else setLoadingStatus([true, 'backendError', true, false]) } const onDrop = useCallback( (acceptedFiles) => { const reader = new FileReader() reader.onload = async () => { if (active) upload(reader.result) else update(reader.result) } acceptedFiles.forEach((file) => reader.readAsDataURL(file)) }, [current] ) const { getRootProps, getInputProps } = useDropzone({ onDrop }) if (current) return ( <FormControl label={label}> <div className="tw:bg-base-100 tw:w-full tw:h-36 tw:mb-2 tw:mx-auto tw:flex tw:flex-col tw:items-center tw:text-center tw:justify-center" style={{ backgroundImage: `url(${ uploadedId ? cloudflareImageUrl({ type: 'public', id: uploadedId }) : current })`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: '50%', }} > <button className="tw:daisy-btn tw:daisy-btn-neutral tw:daisy-btn-circle tw:opacity-50 tw:hover:opacity-100" onClick={() => update(original)} > <ResetIcon /> </button> </div> </FormControl> ) return ( <FormControl label={label} forId={id}> <div {...getRootProps()} className={` tw:flex tw:rounded-lg tw:w-full tw:flex-col tw:items-center tw:justify-center tw:lg:p-6 tw:lg:border-4 tw:lg:border-secondary tw:lg:border-dashed `} > <input {...getInputProps()} /> <p className="tw:hidden tw:lg:block tw:p-0 tw:m-0">Drag and drop and image here</p> <p className="tw:hidden tw:lg:block tw:p-0 tw:my-2">or</p> <button className={`tw:daisy-btn tw:daisy-btn-secondary tw:daisy-btn-outline tw:mt-4 tw:px-8`} > Select an image to use </button> </div> <p className="tw:p-0 tw:my-2 tw:text-center">or</p> <div className="tw:flex tw:flex-row tw:items-center"> <input id={id} type="url" className="tw:daisy-input tw:daisy-input-secondary tw:w-full tw:daisy-input-bordered" placeholder="Paste an image URL here" value={current} onChange={active ? (evt) => setUrl(evt.target.value) : (evt) => update(evt.target.value)} /> {active && ( <button className="tw:daisy-btn tw:daisy-btn-secondary tw:ml-2 tw:capitalize" disabled={!url || url.length < 1} onClick={() => upload(url, true)} > <UploadIcon /> Upload </button> )} </div> </FormControl> ) } /* * Input for an image that is active (it does upload the image) */ export const ActiveImageInput = (props) => <ImageInput {...props} active={true} /> /* * Input for an image that is passive (it does not upload the image) */ export const PassiveImageInput = (props) => <ImageInput {...props} active={false} /> /* * Input for a list of things to pick from */ export const ListInput = ({ update, // the onChange handler label, // The label list, // The list of items to present { val, label, desc } current, // The (value of the) current item }) => ( <FormControl label={label}> {list.map((item, i) => ( <ButtonFrame key={i} active={item.val === current} onClick={() => update(item.val)}> <div className="tw:w-full tw:flex tw:flex-col tw:gap-2"> <div className="tw:w-full tw:text-lg tw:leading-5">{item.label}</div> {item.desc ? ( <div className="tw:w-full tw:text-normal tw:font-normal tw:normal-case tw:pt-1 tw:leading-5"> {item.desc} </div> ) : null} </div> </ButtonFrame> ))} </FormControl> ) /* * Input for markdown content */ export const MarkdownInput = ({ label, // The label current, // The current value (markdown) update, // The onChange handler placeholder, // The placeholder content id = '', // An id to tie the input to the label labelBL = false, // Bottom-Left label labelBR = false, // Bottom-Right label }) => ( <FormControl {...{ label, labelBR }} forId={id} labelBL={labelBL ? labelBL : 'This field supports markdown'} > <Tabs tabs={['edit', 'preview']}> <Tab key="edit"> <div className="tw:flex tw:flex-row tw:items-center tw:mt-2"> <textarea id={id} rows="5" className="tw:daisy-textarea tw:daisy-textarea-bordered tw:daisy-textarea-lg tw:w-full" value={current} placeholder={placeholder} onChange={(evt) => update(evt.target.value)} /> </div> </Tab> <Tab key="preview"> <div className="mdx markdown"> <Markdown>{current}</Markdown> </div> </Tab> </Tabs> </FormControl> ) export const MeasurementInput = ({ imperial, // True for imperial, False for metric m, // The measurement name original, // The original value update, // The onChange handler placeholder, // The placeholder content id = '', // An id to tie the input to the label helpProvider = false, // a function that provides a url or an action to display help for a measurement }) => { const isDegree = isDegreeMeasurement(m) const units = imperial ? 'imperial' : 'metric' const [localVal, setLocalVal] = useState( typeof original === 'undefined' ? original : isDegree ? Number(original) : measurementAsUnits(original, units) ) const [validatedVal, setValidatedVal] = useState(measurementAsUnits(original, units)) const [valid, setValid] = useState(null) // Update onChange const localUpdate = (newVal) => { setLocalVal(newVal) const parsedVal = isDegree ? Number(newVal) : distanceAsMm(newVal, imperial) if (parsedVal) { update(m, isDegree ? parsedVal : measurementAsMm(parsedVal, units)) setValid(true) setValidatedVal(parsedVal) } else if (newVal === undefined) update(m, undefined) else setValid(false) } // Clear value const clearValue = () => { localUpdate(undefined) setLocalVal('') } if (!m) return null // Various visual indicators for validating the input let inputClasses = 'daisy-input-secondary' let bottomLeftLabel = null if (valid === true) { inputClasses = 'daisy-input-success tw:outline-success' const val = `${validatedVal}${isDegree ? '°' : imperial ? '"' : 'cm'}` bottomLeftLabel = ( <span className="tw:font-medium tw:text-base tw:text-success tw:-mt-2 tw:block">{val}</span> ) } else if (valid === false) { inputClasses = 'daisy-input-error' bottomLeftLabel = ( <span className="tw:font-medium tw:text-error tw:text-base tw:-mt-2 tw:block"> ¯\_(ツ)_/¯ </span> ) } /* * I'm on the fence here about using a text input rather than number * Obviously, number is the more correct option, but when the user enter * text, it won't fire an onChange event and thus they can enter text and it * will not be marked as invalid input. * See: https://github.com/facebook/react/issues/16554 */ return ( <FormControl label={measurementsTranslations[m] + (isDegree ? ' (°)' : '')} forId={id} help={typeof helpProvider === 'function' ? helpProvider(m) : helpProvider} labelBL={bottomLeftLabel} > <label className={`tw:daisy-input tw:daisy-input-bordered tw:flex tw:items-center tw:gap-2 tw:border ${inputClasses} tw:mb-1 tw:outline tw:outline-base-300 tw:bg-transparent tw:outline-2 tw:outline-offset-2`} > <input id={id} type="text" inputMode="numeric" pattern="[0-9]*" placeholder={placeholder} value={localVal} onChange={(evt) => localUpdate(evt.target.value)} className={`tw:border-0 tw:grow-2 tw:w-full`} /> {isDegree ? '°' : imperial ? 'inch' : 'cm'} <label> <button className="tw:text-warning tw:hover:text-error" onClick={clearValue}> <TrashIcon className="tw:w-5 tw:h-5 tw:-mb-1" /> </button> </label> </label> </FormControl> ) } export const FileInput = ({ label, // The label valid = () => true, // Method that should return whether the value is valid or not update, // The onChange handler current, // The current value original, // The original value id = '', // An id to tie the input to the label dropzoneConfig = {}, // Configuration for react-dropzone }) => { /* * Ondrop handler */ const onDrop = useCallback( (acceptedFiles) => { const reader = new FileReader() reader.onload = async () => update(reader.result) acceptedFiles.forEach((file) => reader.readAsDataURL(file)) }, [update] ) /* * Dropzone hook */ const { getRootProps, getInputProps } = useDropzone({ onDrop, ...dropzoneConfig }) /* * If we have a current file, return this */ if (current) return ( <FormControl label={label} isValid={valid(current)}> <div className="tw:bg-base-100 tw:w-full tw:h-36 tw:mb-2 tw:mx-auto tw:flex tw:flex-col tw:items-center tw:text-center tw:justify-center"> <button className="tw:daisy-btn tw:daisy-btn-neutral tw:daisy-btn-circle tw:opacity-50 tw:hover:opacity-100" onClick={() => update(original)} > <ResetIcon /> </button> </div> </FormControl> ) /* * Return upload form */ return ( <FormControl label={label} forId={id} isValid={valid(current)}> <div {...getRootProps()} className={` tw:flex tw:rounded-lg tw:w-full tw:flex-col tw:items-center tw:justify-center tw:sm:p-6 tw:sm:border-4 tw:sm:border-secondary tw:sm:border-dashed `} > <input {...getInputProps()} /> <p className="tw:hidden tw:lg:block tw:p-0 tw:m-0">Drag and drop your file here</p> <button className={`tw:daisy-btn tw:daisy-btn-secondary tw:daisy-btn-outline tw:mt-4 tw:px-8`} > Browse... </button> </div> </FormControl> ) } /* * Input for booleans */ export const ToggleInput = ({ label, // Label to use update, // onChange handler current, // The current value disabled = false, // Allows rendering a disabled view list = [true, false], // The values to chose between labels = ['Yes', 'No'], // The labels for the values on = true, // The value that should show the toggle in the 'on' state id = '', // An id to tie the input to the label labelTR = false, // Top-Right label labelBL = false, // Bottom-Left label labelBR = false, // Bottom-Right label }) => ( <FormControl {...{ labelBL, labelBR, labelTR }} label={ label ? `${label} (${current === on ? labels[0] : labels[1]})` : `${current === on ? labels[0] : labels[1]}` } forId={id} > <input id={id} disabled={disabled} type="checkbox" value={current} onChange={() => update(list.indexOf(current) === 0 ? list[1] : list[0])} className="tw:daisy-toggle tw:my-3 tw:daisy-toggle-primary" checked={list.indexOf(current) === 0 ? true : false} /> </FormControl> )