diff options
Diffstat (limited to 'packages/anastasis-webui/src/components')
14 files changed, 1066 insertions, 40 deletions
diff --git a/packages/anastasis-webui/src/components/AsyncButton.tsx b/packages/anastasis-webui/src/components/AsyncButton.tsx new file mode 100644 index 000000000..92bef2219 --- /dev/null +++ b/packages/anastasis-webui/src/components/AsyncButton.tsx @@ -0,0 +1,49 @@ +/* + 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 { LoadingModal } from "../modal"; +import { useAsync } from "../hooks/async"; +// import { Translate } from "../../i18n"; + +type Props = { + children: ComponentChildren; + disabled?: boolean; + onClick?: () => Promise<void>; + [rest: string]: any; +}; + +export function AsyncButton({ onClick, disabled, children, ...rest }: Props): VNode { + const { isLoading, request } = useAsync(onClick); + + // 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} onClick={request} disabled={disabled}> + {children} + </button> + </span>; +} diff --git a/packages/anastasis-webui/src/components/Notifications.tsx b/packages/anastasis-webui/src/components/Notifications.tsx new file mode 100644 index 000000000..c916020d7 --- /dev/null +++ b/packages/anastasis-webui/src/components/Notifications.tsx @@ -0,0 +1,59 @@ +/* + 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> + <button class="delete" onClick={() => removeNotification && removeNotification(n)} /> + </div> + {n.description && <div class="message-body"> + {n.description} + </div>} + </article>)} + </div> +}
\ No newline at end of file diff --git a/packages/anastasis-webui/src/components/QR.tsx b/packages/anastasis-webui/src/components/QR.tsx new file mode 100644 index 000000000..48f1a7c12 --- /dev/null +++ b/packages/anastasis-webui/src/components/QR.tsx @@ -0,0 +1,35 @@ +/* + 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: 'center' }}> + <div style={{ width: '50%', minWidth: 200, maxWidth: 300 }} ref={divRef} /> + </div>; +} diff --git a/packages/anastasis-webui/src/components/fields/DateInput.tsx b/packages/anastasis-webui/src/components/fields/DateInput.tsx new file mode 100644 index 000000000..3148c953f --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/DateInput.tsx @@ -0,0 +1,74 @@ +import { format, isAfter, parse, sub, 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>; + 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} + 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/anastasis-webui/src/components/fields/EmailInput.tsx b/packages/anastasis-webui/src/components/fields/EmailInput.tsx new file mode 100644 index 000000000..e21418fea --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/EmailInput.tsx @@ -0,0 +1,44 @@ +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; + 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'} + 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/anastasis-webui/src/components/fields/FileInput.tsx b/packages/anastasis-webui/src/components/fields/FileInput.tsx new file mode 100644 index 000000000..8b144ea43 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/FileInput.tsx @@ -0,0 +1,81 @@ +/* + 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 { TextInputProps } from "./TextInput"; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024 + +export function FileInput(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"> + <a onClick={() => image.current?.click()}> + {props.label} + </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={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("") + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true) + return onChange("") + } + 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"> + File should be smaller than 1 MB + </p>} + </div> + </div> +} + diff --git a/packages/anastasis-webui/src/components/fields/ImageInput.tsx b/packages/anastasis-webui/src/components/fields/ImageInput.tsx new file mode 100644 index 000000000..d5bf643d4 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/ImageInput.tsx @@ -0,0 +1,81 @@ +/* + 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/anastasis-webui/src/components/fields/NumberInput.tsx b/packages/anastasis-webui/src/components/fields/NumberInput.tsx new file mode 100644 index 000000000..2afb242b8 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/NumberInput.tsx @@ -0,0 +1,43 @@ +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; + bind: [string, (x: string) => void]; +} + +export function NumberInput(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="number" + placeholder={props.placeholder} + class={showError ? 'input is-danger' : 'input'} + 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/anastasis-webui/src/components/fields/TextInput.tsx b/packages/anastasis-webui/src/components/fields/TextInput.tsx new file mode 100644 index 000000000..c093689c5 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/TextInput.tsx @@ -0,0 +1,42 @@ +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; + bind: [string, (x: string) => void]; +} + +export function TextInput(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} + placeholder={props.placeholder} + class={showError ? 'input is-danger' : 'input'} + 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/anastasis-webui/src/components/menu/NavigationBar.tsx b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx index e1bb4c7c0..935951ab9 100644 --- a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx +++ b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx @@ -49,7 +49,7 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode { </a> <div class="navbar-end"> <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> - <LangSelector /> + {/* <LangSelector /> */} </div> </div> </div> diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx index df582a5d0..72655662f 100644 --- a/packages/anastasis-webui/src/components/menu/SideBar.tsx +++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx @@ -33,14 +33,15 @@ interface Props { 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' } } const reducer = useAnastasisContext()! return ( <aside class="aside is-placed-left is-expanded"> - {mobile && <div class="footer" onClick={(e) => { return e.stopImmediatePropagation() }}> + {/* {mobile && <div class="footer" onClick={(e) => { return e.stopImmediatePropagation() }}> <LangSelector /> - </div>} + </div>} */} <div class="aside-tools"> <div class="aside-tools-label"> <div><b>Anastasis</b> Reducer</div> @@ -59,97 +60,84 @@ export function Sidebar({ mobile }: Props): VNode { {!reducer.currentReducerState && <li> <div class="ml-4"> - <span class="menu-item-label"><Translate>Start one options</Translate></span> + <span class="menu-item-label"><Translate>Select one option</Translate></span> </div> </li> } {reducer.currentReducerState && reducer.currentReducerState.backup_state ? <Fragment> - <li class={reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting ? 'is-active' : ''}> + <li class={reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting || + reducer.currentReducerState.backup_state === BackupStates.CountrySelecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>Continent selection</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.CountrySelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Country selection</Translate></span> + <span class="menu-item-label"><Translate>Location</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.UserAttributesCollecting ? 'is-active' : ''}> <div class="ml-4"> - - <span class="menu-item-label"><Translate>User attributes</Translate></span> + <span class="menu-item-label"><Translate>Personal information</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.AuthenticationsEditing ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>Auth methods</Translate></span> + <span class="menu-item-label"><Translate>Authorization methods</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesReviewing ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>PoliciesReviewing</Translate></span> + <span class="menu-item-label"><Translate>Policies</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.SecretEditing ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>SecretEditing</Translate></span> + <span class="menu-item-label"><Translate>Secret input</Translate></span> </div> </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>PoliciesPaying</Translate></span> + <span class="menu-item-label"><Translate>Payment (optional)</Translate></span> </div> - </li> + </li> */} <li class={reducer.currentReducerState.backup_state === BackupStates.BackupFinished ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>BackupFinished</Translate></span> + <span class="menu-item-label"><Translate>Backup completed</Translate></span> </div> </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>TruthsPaying</Translate></span> + <span class="menu-item-label"><Translate>Truth Paying</Translate></span> </div> - </li> + </li> */} </Fragment> : (reducer.currentReducerState && reducer.currentReducerState?.recovery_state && <Fragment> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting ? 'is-active' : ''}> + <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting || + reducer.currentReducerState.recovery_state === RecoveryStates.CountrySelecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>TruthsPaying</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.CountrySelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>CountrySelecting</Translate></span> + <span class="menu-item-label"><Translate>Location</Translate></span> </div> </li> <li class={reducer.currentReducerState.recovery_state === RecoveryStates.UserAttributesCollecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>UserAttributesCollecting</Translate></span> + <span class="menu-item-label"><Translate>Personal information</Translate></span> </div> </li> <li class={reducer.currentReducerState.recovery_state === RecoveryStates.SecretSelecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>SecretSelecting</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>ChallengeSelecting</Translate></span> + <span class="menu-item-label"><Translate>Secret selection</Translate></span> </div> </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSolving ? 'is-active' : ''}> + <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSelecting || + reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSolving ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>ChallengeSolving</Translate></span> + <span class="menu-item-label"><Translate>Solve Challenges</Translate></span> </div> </li> <li class={reducer.currentReducerState.recovery_state === RecoveryStates.RecoveryFinished ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>RecoveryFinished</Translate></span> + <span class="menu-item-label"><Translate>Secret recovered</Translate></span> </div> </li> </Fragment>)} diff --git a/packages/anastasis-webui/src/components/picker/DatePicker.tsx b/packages/anastasis-webui/src/components/picker/DatePicker.tsx new file mode 100644 index 000000000..eb5d8145d --- /dev/null +++ b/packages/anastasis-webui/src/components/picker/DatePicker.tsx @@ -0,0 +1,326 @@ +/* + 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/anastasis-webui/src/components/picker/DurationPicker.stories.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx new file mode 100644 index 000000000..275c80fa6 --- /dev/null +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx @@ -0,0 +1,50 @@ +/* + 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/anastasis-webui/src/components/picker/DurationPicker.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx new file mode 100644 index 000000000..235a63e2d --- /dev/null +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx @@ -0,0 +1,154 @@ +/* + 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}`; +}
\ No newline at end of file |