diff options
Diffstat (limited to 'packages/demobank-ui/src/components')
18 files changed, 1711 insertions, 0 deletions
diff --git a/packages/demobank-ui/src/components/AsyncButton.tsx b/packages/demobank-ui/src/components/AsyncButton.tsx new file mode 100644 index 000000000..0c4305668 --- /dev/null +++ b/packages/demobank-ui/src/components/AsyncButton.tsx @@ -0,0 +1,66 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ComponentChildren, h, VNode } from 'preact'; +import { useLayoutEffect, useRef } from 'preact/hooks'; +// import { LoadingModal } from "../modal"; +import { useAsync } from '../hooks/async'; +// import { Translate } from "../../i18n"; + +type Props = { +  children: ComponentChildren; +  disabled?: boolean; +  onClick?: () => Promise<void>; +  grabFocus?: boolean; +  [rest: string]: any; +}; + +export function AsyncButton({ +  onClick, +  grabFocus, +  disabled, +  children, +  ...rest +}: Props): VNode { +  const { isLoading, request } = useAsync(onClick); + +  const buttonRef = useRef<HTMLButtonElement>(null); +  useLayoutEffect(() => { +    if (grabFocus)  +      buttonRef.current?.focus(); +     +  }, [grabFocus]); + +  // if (isSlow) { +  //   return <LoadingModal onCancel={cancel} />; +  // } +  if (isLoading)  +    return <button class="button">Loading...</button>; +   + +  return ( +    <span data-tooltip={rest['data-tooltip']} style={{ marginLeft: 5 }}> +      <button {...rest} ref={buttonRef} onClick={request} disabled={disabled}> +        {children} +      </button> +    </span> +  ); +} diff --git a/packages/demobank-ui/src/components/FileButton.tsx b/packages/demobank-ui/src/components/FileButton.tsx new file mode 100644 index 000000000..dba86ccbf --- /dev/null +++ b/packages/demobank-ui/src/components/FileButton.tsx @@ -0,0 +1,57 @@ +import { h, VNode } from 'preact'; +import { useRef, useState } from 'preact/hooks'; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export interface FileTypeContent { +  content: string; +  type: string; +  name: string; +} + +interface Props { +  label: string; +  onChange: (v: FileTypeContent | undefined) => void; +} +export function FileButton(props: Props): VNode { +  const fileInputRef = useRef<HTMLInputElement>(null); +  const [sizeError, setSizeError] = useState(false); +  return ( +    <div> +      <button class="button" onClick={(e) => fileInputRef.current?.click()}> +        <span>{props.label}</span> +      </button> +      <input +        ref={fileInputRef} +        style={{ display: 'none' }} +        type="file" +        onChange={(e) => { +          const f: FileList | null = e.currentTarget.files; +          if (!f || f.length != 1)  +            return props.onChange(undefined); +           +          console.log(f); +          if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { +            setSizeError(true); +            return props.onChange(undefined); +          } +          setSizeError(false); +          return f[0].arrayBuffer().then((b) => { +            const content = new Uint8Array(b).reduce( +              (data, byte) => data + String.fromCharCode(byte), +              '', +            ); +            return props.onChange({ +              content, +              name: f[0].name, +              type: f[0].type, +            }); +          }); +        }} +      /> +      {sizeError && ( +        <p class="help is-danger">File should be smaller than 1 MB</p> +      )} +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/Notifications.tsx b/packages/demobank-ui/src/components/Notifications.tsx new file mode 100644 index 000000000..09329442a --- /dev/null +++ b/packages/demobank-ui/src/components/Notifications.tsx @@ -0,0 +1,74 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from 'preact'; + +export interface Notification { +  message: string; +  description?: string | VNode; +  type: MessageType; +} + +export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS'; + +interface Props { +  notifications: Notification[]; +  removeNotification?: (n: Notification) => void; +} + +function messageStyle(type: MessageType): string { +  switch (type) { +  case 'INFO': +    return 'message is-info'; +  case 'WARN': +    return 'message is-warning'; +  case 'ERROR': +    return 'message is-danger'; +  case 'SUCCESS': +    return 'message is-success'; +  default: +    return 'message'; +  } +} + +export function Notifications({ +  notifications, +  removeNotification, +}: Props): VNode { +  return ( +    <div class="block"> +      {notifications.map((n, i) => ( +        <article key={i} class={messageStyle(n.type)}> +          <div class="message-header"> +            <p>{n.message}</p> +            {removeNotification && ( +              <button +                class="delete" +                onClick={() => removeNotification && removeNotification(n)} +              /> +            )} +          </div> +          {n.description && <div class="message-body">{n.description}</div>} +        </article> +      ))} +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/QR.tsx b/packages/demobank-ui/src/components/QR.tsx new file mode 100644 index 000000000..ee5b73c69 --- /dev/null +++ b/packages/demobank-ui/src/components/QR.tsx @@ -0,0 +1,48 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +import { h, VNode } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; +import qrcode from 'qrcode-generator'; + +export function QR({ text }: { text: string }): VNode { +  const divRef = useRef<HTMLDivElement>(null); +  useEffect(() => { +    const qr = qrcode(0, 'L'); +    qr.addData(text); +    qr.make(); +    if (divRef.current) +      divRef.current.innerHTML = qr.createSvgTag({ +        scalable: true, +      }); +  }); + +  return ( +    <div +      style={{ +        width: '100%', +        display: 'flex', +        flexDirection: 'column', +        alignItems: 'left', +      }} +    > +      <div +        style={{ width: '50%', minWidth: 200, maxWidth: 300 }} +        ref={divRef} +      /> +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx new file mode 100644 index 000000000..5338c548e --- /dev/null +++ b/packages/demobank-ui/src/components/app.tsx @@ -0,0 +1,14 @@ +import { FunctionalComponent, h } from 'preact'; +import { TranslationProvider } from '../context/translation'; +import { BankHome } from '../pages/home/index'; +import { Menu } from './menu'; + +const App: FunctionalComponent = () => { +  return ( +    <TranslationProvider> +      <BankHome /> +    </TranslationProvider> +  ); +}; + +export default App; diff --git a/packages/demobank-ui/src/components/fields/DateInput.tsx b/packages/demobank-ui/src/components/fields/DateInput.tsx new file mode 100644 index 000000000..06ec4b6a7 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/DateInput.tsx @@ -0,0 +1,90 @@ +import { format, subYears } from 'date-fns'; +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; +import { DatePicker } from '../picker/DatePicker'; + +export interface DateInputProps { +  label: string; +  grabFocus?: boolean; +  tooltip?: string; +  error?: string; +  years?: Array<number>; +  onConfirm?: () => void; +  bind: [string, (x: string) => void]; +} + +export function DateInput(props: DateInputProps): VNode { +  const inputRef = useRef<HTMLInputElement>(null); +  useLayoutEffect(() => { +    if (props.grabFocus)  +      inputRef.current?.focus(); +     +  }, [props.grabFocus]); +  const [opened, setOpened] = useState(false); + +  const value = props.bind[0] || ''; +  const [dirty, setDirty] = useState(false); +  const showError = dirty && props.error; + +  const calendar = subYears(new Date(), 30); + +  return ( +    <div class="field"> +      <label class="label"> +        {props.label} +        {props.tooltip && ( +          <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> +            <i class="mdi mdi-information" /> +          </span> +        )} +      </label> +      <div class="control"> +        <div class="field has-addons"> +          <p class="control"> +            <input +              type="text" +              class={showError ? 'input is-danger' : 'input'} +              value={value} +              onKeyPress={(e) => { +                if (e.key === 'Enter' && props.onConfirm)  +                  props.onConfirm() +                 +              }} +              onInput={(e) => { +                const text = e.currentTarget.value; +                setDirty(true); +                props.bind[1](text); +              }} +              ref={inputRef} +            /> +          </p> +          <p class="control"> +            <a +              class="button" +              onClick={() => { +                setOpened(true); +              }} +            > +              <span class="icon"> +                <i class="mdi mdi-calendar" /> +              </span> +            </a> +          </p> +        </div> +      </div> +      <p class="help">Using the format yyyy-mm-dd</p> +      {showError && <p class="help is-danger">{props.error}</p>} +      <DatePicker +        opened={opened} +        initialDate={calendar} +        years={props.years} +        closeFunction={() => setOpened(false)} +        dateReceiver={(d) => { +          setDirty(true); +          const v = format(d, 'yyyy-MM-dd'); +          props.bind[1](v); +        }} +      /> +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/fields/EmailInput.tsx b/packages/demobank-ui/src/components/fields/EmailInput.tsx new file mode 100644 index 000000000..8b64264ed --- /dev/null +++ b/packages/demobank-ui/src/components/fields/EmailInput.tsx @@ -0,0 +1,57 @@ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +export interface TextInputProps { +  label: string; +  grabFocus?: boolean; +  error?: string; +  placeholder?: string; +  tooltip?: string; +  onConfirm?: () => void; +  bind: [string, (x: string) => void]; +} + +export function EmailInput(props: TextInputProps): VNode { +  const inputRef = useRef<HTMLInputElement>(null); +  useLayoutEffect(() => { +    if (props.grabFocus)  +      inputRef.current?.focus(); +     +  }, [props.grabFocus]); +  const value = props.bind[0]; +  const [dirty, setDirty] = useState(false); +  const showError = dirty && props.error; +  return ( +    <div class="field"> +      <label class="label"> +        {props.label} +        {props.tooltip && ( +          <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> +            <i class="mdi mdi-information" /> +          </span> +        )} +      </label> +      <div class="control has-icons-right"> +        <input +          value={value} +          required +          placeholder={props.placeholder} +          type="email" +          class={showError ? 'input is-danger' : 'input'} +          onKeyPress={(e) => { +            if (e.key === 'Enter' && props.onConfirm)  +              props.onConfirm() +             +          }} +          onInput={(e) => { +            setDirty(true); +            props.bind[1]((e.target as HTMLInputElement).value); +          }} +          ref={inputRef} +          style={{ display: 'block' }} +        /> +      </div> +      {showError && <p class="help is-danger">{props.error}</p>} +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/fields/FileInput.tsx b/packages/demobank-ui/src/components/fields/FileInput.tsx new file mode 100644 index 000000000..17413b907 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/FileInput.tsx @@ -0,0 +1,104 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export interface FileTypeContent { +  content: string; +  type: string; +  name: string; +} + +export interface FileInputProps { +  label: string; +  grabFocus?: boolean; +  disabled?: boolean; +  error?: string; +  placeholder?: string; +  tooltip?: string; +  onChange: (v: FileTypeContent | undefined) => void; +} + +export function FileInput(props: FileInputProps): VNode { +  const inputRef = useRef<HTMLInputElement>(null); +  useLayoutEffect(() => { +    if (props.grabFocus)  +      inputRef.current?.focus(); +     +  }, [props.grabFocus]); + +  const fileInputRef = useRef<HTMLInputElement>(null); +  const [sizeError, setSizeError] = useState(false); +  return ( +    <div class="field"> +      <label class="label"> +        <a class="button" onClick={(e) => fileInputRef.current?.click()}> +          <div class="icon is-small "> +            <i class="mdi mdi-folder" /> +          </div> +          <span> +            {props.label} +          </span> +        </a> +        {props.tooltip && ( +          <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> +            <i class="mdi mdi-information" /> +          </span> +        )} +      </label> +      <div class="control"> +        <input +          ref={fileInputRef} +          style={{ display: 'none' }} +          type="file" +          // name={String(name)} +          onChange={(e) => { +            const f: FileList | null = e.currentTarget.files; +            if (!f || f.length != 1)  +              return props.onChange(undefined); +             +            console.log(f) +            if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { +              setSizeError(true); +              return props.onChange(undefined); +            } +            setSizeError(false); +            return f[0].arrayBuffer().then((b) => { +              const b64 = btoa( +                new Uint8Array(b).reduce( +                  (data, byte) => data + String.fromCharCode(byte), +                  '', +                ), +              ); +              return props.onChange({content: `data:${f[0].type};base64,${b64}`, name: f[0].name, type: f[0].type}); +            }); +          }} +        /> +        {props.error && <p class="help is-danger">{props.error}</p>} +        {sizeError && ( +          <p class="help is-danger">File should be smaller than 1 MB</p> +        )} +      </div> +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/fields/ImageInput.tsx b/packages/demobank-ui/src/components/fields/ImageInput.tsx new file mode 100644 index 000000000..98457af21 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/ImageInput.tsx @@ -0,0 +1,93 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; +import emptyImage from '../../assets/empty.png'; +import { TextInputProps } from './TextInput'; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export function ImageInput(props: TextInputProps): VNode { +  const inputRef = useRef<HTMLInputElement>(null); +  useLayoutEffect(() => { +    if (props.grabFocus)  +      inputRef.current?.focus(); +     +  }, [props.grabFocus]); + +  const value = props.bind[0]; +  // const [dirty, setDirty] = useState(false) +  const image = useRef<HTMLInputElement>(null); +  const [sizeError, setSizeError] = useState(false); +  function onChange(v: string): void { +    // setDirty(true); +    props.bind[1](v); +  } +  return ( +    <div class="field"> +      <label class="label"> +        {props.label} +        {props.tooltip && ( +          <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> +            <i class="mdi mdi-information" /> +          </span> +        )} +      </label> +      <div class="control"> +        <img +          src={!value ? emptyImage : value} +          style={{ width: 200, height: 200 }} +          onClick={() => image.current?.click()} +        /> +        <input +          ref={image} +          style={{ display: 'none' }} +          type="file" +          name={String(name)} +          onChange={(e) => { +            const f: FileList | null = e.currentTarget.files; +            if (!f || f.length != 1)  +              return onChange(emptyImage); +             +            if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { +              setSizeError(true); +              return onChange(emptyImage); +            } +            setSizeError(false); +            return f[0].arrayBuffer().then((b) => { +              const b64 = btoa( +                new Uint8Array(b).reduce( +                  (data, byte) => data + String.fromCharCode(byte), +                  '', +                ), +              ); +              return onChange(`data:${f[0].type};base64,${b64}` as any); +            }); +          }} +        /> +        {props.error && <p class="help is-danger">{props.error}</p>} +        {sizeError && ( +          <p class="help is-danger">Image should be smaller than 1 MB</p> +        )} +      </div> +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/fields/NumberInput.tsx b/packages/demobank-ui/src/components/fields/NumberInput.tsx new file mode 100644 index 000000000..881c61c57 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/NumberInput.tsx @@ -0,0 +1,56 @@ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +export interface TextInputProps { +  label: string; +  grabFocus?: boolean; +  error?: string; +  placeholder?: string; +  tooltip?: string; +  onConfirm?: () => void; +  bind: [string, (x: string) => void]; +} + +export function PhoneNumberInput(props: TextInputProps): VNode { +  const inputRef = useRef<HTMLInputElement>(null); +  useLayoutEffect(() => { +    if (props.grabFocus)  +      inputRef.current?.focus(); +     +  }, [props.grabFocus]); +  const value = props.bind[0]; +  const [dirty, setDirty] = useState(false); +  const showError = dirty && props.error; +  return ( +    <div class="field"> +      <label class="label"> +        {props.label} +        {props.tooltip && ( +          <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> +            <i class="mdi mdi-information" /> +          </span> +        )} +      </label> +      <div class="control has-icons-right"> +        <input +          value={value} +          type="tel" +          placeholder={props.placeholder} +          class={showError ? 'input is-danger' : 'input'} +          onKeyPress={(e) => { +            if (e.key === 'Enter' && props.onConfirm)  +              props.onConfirm() +             +          }} +          onInput={(e) => { +            setDirty(true); +            props.bind[1]((e.target as HTMLInputElement).value); +          }} +          ref={inputRef} +          style={{ display: 'block' }} +        /> +      </div> +      {showError && <p class="help is-danger">{props.error}</p>} +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/fields/TextInput.tsx b/packages/demobank-ui/src/components/fields/TextInput.tsx new file mode 100644 index 000000000..5cc9f32ad --- /dev/null +++ b/packages/demobank-ui/src/components/fields/TextInput.tsx @@ -0,0 +1,68 @@ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +export interface TextInputProps { +  inputType?: 'text' | 'number' | 'multiline' | 'password'; +  label: string; +  grabFocus?: boolean; +  disabled?: boolean; +  error?: string; +  placeholder?: string; +  tooltip?: string; +  onConfirm?: () => void; +  bind: [string, (x: string) => void]; +} + +const TextInputType = function ({ inputType, grabFocus, ...rest }: any): VNode { +  const inputRef = useRef<HTMLInputElement>(null); +  useLayoutEffect(() => { +    if (grabFocus)  +      inputRef.current?.focus(); +     +  }, [grabFocus]); + +  return inputType === 'multiline' ? ( +    <textarea {...rest} rows={5} ref={inputRef} style={{ height: 'unset' }} /> +  ) : ( +    <input {...rest} type={inputType} ref={inputRef} /> +  ); +}; + +export function TextInput(props: TextInputProps): VNode { +  const value = props.bind[0]; +  const [dirty, setDirty] = useState(false); +  const showError = dirty && props.error; +  return ( +    <div class="field"> +      <label class="label"> +        {props.label} +        {props.tooltip && ( +          <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> +            <i class="mdi mdi-information" /> +          </span> +        )} +      </label> +      <div class="control has-icons-right"> +        <TextInputType +          inputType={props.inputType} +          value={value} +          grabFocus={props.grabFocus} +          disabled={props.disabled} +          placeholder={props.placeholder} +          class={showError ? 'input is-danger' : 'input'} +          onKeyPress={(e: any) => { +            if (e.key === 'Enter' && props.onConfirm)  +              props.onConfirm(); +             +          }} +          onInput={(e: any) => { +            setDirty(true); +            props.bind[1]((e.target as HTMLInputElement).value); +          }} +          style={{ display: 'block' }} +        /> +      </div> +      {showError && <p class="help is-danger">{props.error}</p>} +    </div> +  ); +} diff --git a/packages/demobank-ui/src/components/menu/LangSelector.tsx b/packages/demobank-ui/src/components/menu/LangSelector.tsx new file mode 100644 index 000000000..221237a5b --- /dev/null +++ b/packages/demobank-ui/src/components/menu/LangSelector.tsx @@ -0,0 +1,101 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, Fragment } from 'preact'; +import { useCallback, useEffect, useState } from 'preact/hooks'; +import langIcon from '../../assets/icons/languageicon.svg'; +import { useTranslationContext } from '../../context/translation'; +import { strings as messages } from '../../i18n/strings'; + +type LangsNames = { +  [P in keyof typeof messages]: string; +}; + +const names: LangsNames = { +  es: 'Español [es]', +  en: 'English [en]', +  fr: 'Français [fr]', +  de: 'Deutsch [de]', +  sv: 'Svenska [sv]', +  it: 'Italiano [it]', +}; + +function getLangName(s: keyof LangsNames | string): string { +  if (names[s]) return names[s]; +  return String(s); +} + +// FIXME: explain "like py". +export function LangSelectorLikePy(): VNode { +  const [updatingLang, setUpdatingLang] = useState(false); +  const { lang, changeLanguage } = useTranslationContext(); +  const [hidden, setHidden] = useState(true) +  useEffect(() => { +    function bodyKeyPress(event:KeyboardEvent) { +      if (event.code === 'Escape')  +        setHidden(true); +       +    } +    function bodyOnClick(event:Event) { +      setHidden(true); +    } +    document.body.addEventListener('click', bodyOnClick) +    document.body.addEventListener('keydown', bodyKeyPress as any) +    return () => { +      document.body.removeEventListener('keydown', bodyKeyPress as any) +      document.body.removeEventListener('click', bodyOnClick) +    } +  },[]) +  return ( +    <Fragment> +      <button name="language" onClick={(ev) => { +        setHidden(h => !h); +        ev.stopPropagation(); +      }}> +        {getLangName(lang)} +      </button> +      <div id="lang" class={hidden ? 'hide' : ''}> +        <div style="position: relative; overflow: visible;"> +          <div +            class="nav" +            style="position: absolute; max-height: 60vh; overflow-y: scroll"> +            {Object.keys(messages) +              .filter((l) => l !== lang) +              .map((l) => ( +                <a +                  key={l} +                  href="#" +                  class="navbtn langbtn" +                  value={l} +                  onClick={() => { +                    changeLanguage(l); +                    setUpdatingLang(false); +                  }}> +                  {getLangName(l)} +                </a> +              ))} +            <br /> +          </div> +        </div> +      </div> +    </Fragment> +  ); +} diff --git a/packages/demobank-ui/src/components/menu/NavigationBar.tsx b/packages/demobank-ui/src/components/menu/NavigationBar.tsx new file mode 100644 index 000000000..9e540213d --- /dev/null +++ b/packages/demobank-ui/src/components/menu/NavigationBar.tsx @@ -0,0 +1,53 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from 'preact'; +import logo from '../../assets/logo.jpeg'; +import { LangSelectorLikePy as LangSelector } from './LangSelector'; + +interface Props { +  onMobileMenu: () => void; +  title: string; +} + +export function NavigationBar({ onMobileMenu, title }: Props): VNode { +  return ( +    <nav +      class="navbar is-fixed-top" +      role="navigation" +      aria-label="main navigation" +    > +      <div class="navbar-brand"> +        <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}> +          {title} +        </span> +      </div> + +      <div class="navbar-menu "> +        <div class="navbar-end"> +          <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> +            {/* <LangSelector /> */} +          </div> +        </div> +      </div> +    </nav> +  ); +} diff --git a/packages/demobank-ui/src/components/menu/SideBar.tsx b/packages/demobank-ui/src/components/menu/SideBar.tsx new file mode 100644 index 000000000..7f9981a1c --- /dev/null +++ b/packages/demobank-ui/src/components/menu/SideBar.tsx @@ -0,0 +1,73 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from 'preact'; +import { Translate } from '../../i18n'; + +interface Props { +  mobile?: boolean; +} + +export function Sidebar({ mobile }: Props): VNode { +  // const config = useConfigContext(); +  const config = { version: 'none' }; +  // FIXME: add replacement for __VERSION__ with the current version +  const process = { env: { __VERSION__: '0.0.0' } }; + +  return ( +    <aside class="aside is-placed-left is-expanded"> +      <div class="aside-tools"> +        <div class="aside-tools-label"> +          <div> +            <b>euFin bank</b> +          </div> +          <div +            class="is-size-7 has-text-right" +            style={{ lineHeight: 0, marginTop: -10 }} +          > +            Version {process.env.__VERSION__} ({config.version}) +          </div> +        </div> +      </div> +      <div class="menu is-menu-main"> +        <p class="menu-label"> +          <Translate>Bank menu</Translate> +        </p> +        <ul class="menu-list"> +          <li> +            <div class="ml-4"> +              <span class="menu-item-label"> +                <Translate>Select option1</Translate> +              </span> +            </div> +          </li> +          <li> +            <div class="ml-4"> +              <span class="menu-item-label"> +                <Translate>Select option2</Translate> +              </span> +            </div> +          </li> +        </ul> +      </div> +    </aside> +  ); +} diff --git a/packages/demobank-ui/src/components/menu/index.tsx b/packages/demobank-ui/src/components/menu/index.tsx new file mode 100644 index 000000000..07e1c5265 --- /dev/null +++ b/packages/demobank-ui/src/components/menu/index.tsx @@ -0,0 +1,135 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +import { ComponentChildren, Fragment, h, VNode } from 'preact'; +import Match from 'preact-router/match'; +import { useEffect, useState } from 'preact/hooks'; +import { NavigationBar } from './NavigationBar'; +import { Sidebar } from './SideBar'; + +interface MenuProps { +  title: string; +} + +function WithTitle({ +  title, +  children, +}: { +  title: string; +  children: ComponentChildren; +}): VNode { +  useEffect(() => { +    document.title = `${title}`; +  }, [title]); +  return <Fragment>{children}</Fragment>; +} + +export function Menu({ title }: MenuProps): VNode { +  const [mobileOpen, setMobileOpen] = useState(false); + +  return ( +    <Match> +      {({ path }: { path: string }) => { +        const titleWithSubtitle = title; // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance)) +        return ( +          <WithTitle title={titleWithSubtitle}> +            <div +              class={mobileOpen ? 'has-aside-mobile-expanded' : ''} +              onClick={() => setMobileOpen(false)} +            > +              <NavigationBar +                onMobileMenu={() => setMobileOpen(!mobileOpen)} +                title={titleWithSubtitle} +              /> + +              <Sidebar mobile={mobileOpen} /> +            </div> +          </WithTitle> +        ); +      }} +    </Match> +  ); +} + +interface NotYetReadyAppMenuProps { +  title: string; +  onLogout?: () => void; +} + +interface NotifProps { +  notification?: Notification; +} +export function NotificationCard({ +  notification: n, +}: NotifProps): VNode | null { +  if (!n) return null; +  return ( +    <div class="notification"> +      <div class="columns is-vcentered"> +        <div class="column is-12"> +          <article +            class={ +              n.type === 'ERROR' +                ? 'message is-danger' +                : n.type === 'WARN' +                  ? 'message is-warning' +                  : 'message is-info' +            } +          > +            <div class="message-header"> +              <p>{n.message}</p> +            </div> +            {n.description && <div class="message-body">{n.description}</div>} +          </article> +        </div> +      </div> +    </div> +  ); +} + +export function NotYetReadyAppMenu({ +  onLogout, +  title, +}: NotYetReadyAppMenuProps): VNode { +  const [mobileOpen, setMobileOpen] = useState(false); + +  useEffect(() => { +    document.title = `Taler Backoffice: ${title}`; +  }, [title]); + +  return ( +    <div +      class="has-aside-mobile-expanded" +      // class={mobileOpen ? "has-aside-mobile-expanded" : ""} +      onClick={() => setMobileOpen(false)} +    > +      <NavigationBar +        onMobileMenu={() => setMobileOpen(!mobileOpen)} +        title={title} +      /> +      {onLogout && <Sidebar mobile={mobileOpen} />} +    </div> +  ); +} + +export interface Notification { +  message: string; +  description?: string | VNode; +  type: MessageType; +} + +export type ValueOrFunction<T> = T | ((p: T) => T); +export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS'; diff --git a/packages/demobank-ui/src/components/picker/DatePicker.tsx b/packages/demobank-ui/src/components/picker/DatePicker.tsx new file mode 100644 index 000000000..94dbc9458 --- /dev/null +++ b/packages/demobank-ui/src/components/picker/DatePicker.tsx @@ -0,0 +1,356 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, Component } from 'preact'; + +interface Props { +  closeFunction?: () => void; +  dateReceiver?: (d: Date) => void; +  initialDate?: Date; +  years?: Array<number>; +  opened?: boolean; +} +interface State { +  displayedMonth: number; +  displayedYear: number; +  selectYearMode: boolean; +  currentDate: Date; +} +const now = new Date(); + +const monthArrShortFull = [ +  'January', +  'February', +  'March', +  'April', +  'May', +  'June', +  'July', +  'August', +  'September', +  'October', +  'November', +  'December', +]; + +const monthArrShort = [ +  'Jan', +  'Feb', +  'Mar', +  'Apr', +  'May', +  'Jun', +  'Jul', +  'Aug', +  'Sep', +  'Oct', +  'Nov', +  'Dec', +]; + +const dayArr = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +const yearArr: number[] = []; + +// inspired by https://codepen.io/m4r1vs/pen/MOOxyE +export class DatePicker extends Component<Props, State> { +  closeDatePicker() { +    this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent +  } + +  /** +   * Gets fired when a day gets clicked. +   * @param {object} e The event thrown by the <span /> element clicked +   */ +  dayClicked(e: any) { +    const element = e.target; // the actual element clicked + +    if (element.innerHTML === '') return false; // don't continue if <span /> empty + +    // get date from clicked element (gets attached when rendered) +    const date = new Date(element.getAttribute('data-value')); + +    // update the state +    this.setState({ currentDate: date }); +    this.passDateToParent(date); +  } + +  /** +   * returns days in month as array +   * @param {number} month the month to display +   * @param {number} year the year to display +   */ +  getDaysByMonth(month: number, year: number) { +    const calendar = []; + +    const date = new Date(year, month, 1); // month to display + +    const firstDay = new Date(year, month, 1).getDay(); // first weekday of month +    const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month + +    let day: number | null = 0; + +    // the calendar is 7*6 fields big, so 42 loops +    for (let i = 0; i < 42; i++) { +      if (i >= firstDay && day !== null) day = day + 1; +      if (day !== null && day > lastDate) day = null; + +      // append the calendar Array +      calendar.push({ +        day: day === 0 || day === null ? null : day, // null or number +        date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date() +        today: +          day === now.getDate() && +          month === now.getMonth() && +          year === now.getFullYear(), // boolean +      }); +    } + +    return calendar; +  } + +  /** +   * Display previous month by updating state +   */ +  displayPrevMonth() { +    if (this.state.displayedMonth <= 0)  +      this.setState({ +        displayedMonth: 11, +        displayedYear: this.state.displayedYear - 1, +      }); +    else  +      this.setState({ +        displayedMonth: this.state.displayedMonth - 1, +      }); +     +  } + +  /** +   * Display next month by updating state +   */ +  displayNextMonth() { +    if (this.state.displayedMonth >= 11)  +      this.setState({ +        displayedMonth: 0, +        displayedYear: this.state.displayedYear + 1, +      }); +    else  +      this.setState({ +        displayedMonth: this.state.displayedMonth + 1, +      }); +     +  } + +  /** +   * Display the selected month (gets fired when clicking on the date string) +   */ +  displaySelectedMonth() { +    if (this.state.selectYearMode)  +      this.toggleYearSelector(); +    else { +      if (!this.state.currentDate) return false; +      this.setState({ +        displayedMonth: this.state.currentDate.getMonth(), +        displayedYear: this.state.currentDate.getFullYear(), +      }); +    } +  } + +  toggleYearSelector() { +    this.setState({ selectYearMode: !this.state.selectYearMode }); +  } + +  changeDisplayedYear(e: any) { +    const element = e.target; +    this.toggleYearSelector(); +    this.setState({ +      displayedYear: parseInt(element.innerHTML, 10), +      displayedMonth: 0, +    }); +  } + +  /** +   * Pass the selected date to parent when 'OK' is clicked +   */ +  passSavedDateDateToParent() { +    this.passDateToParent(this.state.currentDate); +  } +  passDateToParent(date: Date) { +    if (typeof this.props.dateReceiver === 'function') +      this.props.dateReceiver(date); +    this.closeDatePicker(); +  } + +  componentDidUpdate() { +    // if (this.state.selectYearMode) { +    //   document.getElementsByClassName('selected')[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it +    // } +  } + +  constructor(props: any) { +    super(props); + +    this.closeDatePicker = this.closeDatePicker.bind(this); +    this.dayClicked = this.dayClicked.bind(this); +    this.displayNextMonth = this.displayNextMonth.bind(this); +    this.displayPrevMonth = this.displayPrevMonth.bind(this); +    this.getDaysByMonth = this.getDaysByMonth.bind(this); +    this.changeDisplayedYear = this.changeDisplayedYear.bind(this); +    this.passDateToParent = this.passDateToParent.bind(this); +    this.toggleYearSelector = this.toggleYearSelector.bind(this); +    this.displaySelectedMonth = this.displaySelectedMonth.bind(this); + +    const initial = props.initialDate || now; + +    this.state = { +      currentDate: initial, +      displayedMonth: initial.getMonth(), +      displayedYear: initial.getFullYear(), +      selectYearMode: false, +    }; +  } + +  render() { +    const { +      currentDate, +      displayedMonth, +      displayedYear, +      selectYearMode, +    } = this.state; + +    return ( +      <div> +        <div class={`datePicker ${this.props.opened && 'datePicker--opened'}`}> +          <div class="datePicker--titles"> +            <h3 +              style={{ +                color: selectYearMode +                  ? 'rgba(255,255,255,.87)' +                  : 'rgba(255,255,255,.57)', +              }} +              onClick={this.toggleYearSelector} +            > +              {currentDate.getFullYear()} +            </h3> +            <h2 +              style={{ +                color: !selectYearMode +                  ? 'rgba(255,255,255,.87)' +                  : 'rgba(255,255,255,.57)', +              }} +              onClick={this.displaySelectedMonth} +            > +              {dayArr[currentDate.getDay()]},{' '} +              {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} +            </h2> +          </div> + +          {!selectYearMode && ( +            <nav> +              <span onClick={this.displayPrevMonth} class="icon"> +                <i +                  style={{ transform: 'rotate(180deg)' }} +                  class="mdi mdi-forward" +                /> +              </span> +              <h4> +                {monthArrShortFull[displayedMonth]} {displayedYear} +              </h4> +              <span onClick={this.displayNextMonth} class="icon"> +                <i class="mdi mdi-forward" /> +              </span> +            </nav> +          )} + +          <div class="datePicker--scroll"> +            {!selectYearMode && ( +              <div class="datePicker--calendar"> +                <div class="datePicker--dayNames"> +                  {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( +                    <span key={i}>{day}</span> +                  ))} +                </div> + +                <div onClick={this.dayClicked} class="datePicker--days"> +                  {/* +                  Loop through the calendar object returned by getDaysByMonth(). +                */} + +                  {this.getDaysByMonth( +                    this.state.displayedMonth, +                    this.state.displayedYear, +                  ).map((day) => { +                    let selected = false; + +                    if (currentDate && day.date) +                      selected = +                        currentDate.toLocaleDateString() === +                        day.date.toLocaleDateString(); + +                    return ( +                      <span +                        key={day.day} +                        class={ +                          (day.today ? 'datePicker--today ' : '') + +                          (selected ? 'datePicker--selected' : '') +                        } +                        disabled={!day.date} +                        data-value={day.date} +                      > +                        {day.day} +                      </span> +                    ); +                  })} +                </div> +              </div> +            )} + +            {selectYearMode && ( +              <div class="datePicker--selectYear"> +                {(this.props.years || yearArr).map((year) => ( +                  <span +                    key={year} +                    class={year === displayedYear ? 'selected' : ''} +                    onClick={this.changeDisplayedYear} +                  > +                    {year} +                  </span> +                ))} +              </div> +            )} +          </div> +        </div> + +        <div +          class="datePicker--background" +          onClick={this.closeDatePicker} +          style={{ +            display: this.props.opened ? 'block' : 'none', +          }} +        /> +      </div> +    ); +  } +} + +for (let i = 2010; i <= now.getFullYear() + 10; i++)  +  yearArr.push(i); + diff --git a/packages/demobank-ui/src/components/picker/DurationPicker.stories.tsx b/packages/demobank-ui/src/components/picker/DurationPicker.stories.tsx new file mode 100644 index 000000000..5e9930522 --- /dev/null +++ b/packages/demobank-ui/src/components/picker/DurationPicker.stories.tsx @@ -0,0 +1,55 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, FunctionalComponent } from 'preact'; +import { useState } from 'preact/hooks'; +import { DurationPicker as TestedComponent } from './DurationPicker'; + +export default { +  title: 'Components/Picker/Duration', +  component: TestedComponent, +  argTypes: { +    onCreate: { action: 'onCreate' }, +    goBack: { action: 'goBack' }, +  }, +}; + +function createExample<Props>( +  Component: FunctionalComponent<Props>, +  props: Partial<Props>, +) { +  const r = (args: any) => <Component {...args} />; +  r.args = props; +  return r; +} + +export const Example = createExample(TestedComponent, { +  days: true, +  minutes: true, +  hours: true, +  seconds: true, +  value: 10000000, +}); + +export const WithState = () => { +  const [v, s] = useState<number>(1000000); +  return <TestedComponent value={v} onChange={s} days minutes hours seconds />; +}; diff --git a/packages/demobank-ui/src/components/picker/DurationPicker.tsx b/packages/demobank-ui/src/components/picker/DurationPicker.tsx new file mode 100644 index 000000000..542ff2f01 --- /dev/null +++ b/packages/demobank-ui/src/components/picker/DurationPicker.tsx @@ -0,0 +1,211 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from 'preact'; +import { useState } from 'preact/hooks'; +import { useTranslator } from '../../i18n'; +import '../../scss/DurationPicker.scss'; + +export interface Props { +  hours?: boolean; +  minutes?: boolean; +  seconds?: boolean; +  days?: boolean; +  onChange: (value: number) => void; +  value: number; +} + +// inspiration taken from https://github.com/flurmbo/react-duration-picker +export function DurationPicker({ +  days, +  hours, +  minutes, +  seconds, +  onChange, +  value, +}: Props): VNode { +  const ss = 1000; +  const ms = ss * 60; +  const hs = ms * 60; +  const ds = hs * 24; +  const i18n = useTranslator(); + +  return ( +    <div class="rdp-picker"> +      {days && ( +        <DurationColumn +          unit={i18n`days`} +          max={99} +          value={Math.floor(value / ds)} +          onDecrease={value >= ds ? () => onChange(value - ds) : undefined} +          onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined} +          onChange={(diff) => onChange(value + diff * ds)} +        /> +      )} +      {hours && ( +        <DurationColumn +          unit={i18n`hours`} +          max={23} +          min={1} +          value={Math.floor(value / hs) % 24} +          onDecrease={value >= hs ? () => onChange(value - hs) : undefined} +          onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined} +          onChange={(diff) => onChange(value + diff * hs)} +        /> +      )} +      {minutes && ( +        <DurationColumn +          unit={i18n`minutes`} +          max={59} +          min={1} +          value={Math.floor(value / ms) % 60} +          onDecrease={value >= ms ? () => onChange(value - ms) : undefined} +          onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined} +          onChange={(diff) => onChange(value + diff * ms)} +        /> +      )} +      {seconds && ( +        <DurationColumn +          unit={i18n`seconds`} +          max={59} +          value={Math.floor(value / ss) % 60} +          onDecrease={value >= ss ? () => onChange(value - ss) : undefined} +          onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined} +          onChange={(diff) => onChange(value + diff * ss)} +        /> +      )} +    </div> +  ); +} + +interface ColProps { +  unit: string; +  min?: number; +  max: number; +  value: number; +  onIncrease?: () => void; +  onDecrease?: () => void; +  onChange?: (diff: number) => void; +} + +function InputNumber({ +  initial, +  onChange, +}: { +  initial: number; +  onChange: (n: number) => void; +}) { +  const [value, handler] = useState<{ v: string }>({ +    v: toTwoDigitString(initial), +  }); + +  return ( +    <input +      value={value.v} +      onBlur={(e) => onChange(parseInt(value.v, 10))} +      onInput={(e) => { +        e.preventDefault(); +        const n = Number.parseInt(e.currentTarget.value, 10); +        if (isNaN(n)) return handler({ v: toTwoDigitString(initial) }); +        return handler({ v: toTwoDigitString(n) }); +      }} +      style={{ +        width: 50, +        border: 'none', +        fontSize: 'inherit', +        background: 'inherit', +      }} +    /> +  ); +} + +function DurationColumn({ +  unit, +  min = 0, +  max, +  value, +  onIncrease, +  onDecrease, +  onChange, +}: ColProps): VNode { +  const cellHeight = 35; +  return ( +    <div class="rdp-column-container"> +      <div class="rdp-masked-div"> +        <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} /> +        <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} /> + +        <div class="rdp-column" style={{ top: 0 }}> +          <div class="rdp-cell" key={value - 2}> +            {onDecrease && ( +              <button +                style={{ width: '100%', textAlign: 'center', margin: 5 }} +                onClick={onDecrease} +              > +                <span class="icon"> +                  <i class="mdi mdi-chevron-up" /> +                </span> +              </button> +            )} +          </div> +          <div class="rdp-cell" key={value - 1}> +            {value > min ? toTwoDigitString(value - 1) : ''} +          </div> +          <div class="rdp-cell rdp-center" key={value}> +            {onChange ? ( +              <InputNumber +                initial={value} +                onChange={(n) => onChange(n - value)} +              /> +            ) : ( +              toTwoDigitString(value) +            )} +            <div>{unit}</div> +          </div> + +          <div class="rdp-cell" key={value + 1}> +            {value < max ? toTwoDigitString(value + 1) : ''} +          </div> + +          <div class="rdp-cell" key={value + 2}> +            {onIncrease && ( +              <button +                style={{ width: '100%', textAlign: 'center', margin: 5 }} +                onClick={onIncrease} +              > +                <span class="icon"> +                  <i class="mdi mdi-chevron-down" /> +                </span> +              </button> +            )} +          </div> +        </div> +      </div> +    </div> +  ); +} + +function toTwoDigitString(n: number) { +  if (n < 10)  +    return `0${n}`; +   +  return `${n}`; +}  | 
