refactor: mobile modals (#2173)

This commit is contained in:
Aman Harwara 2023-01-24 19:26:20 +05:30 committed by GitHub
parent 6af95ddfeb
commit 42db3592b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1582 additions and 1033 deletions

View File

@ -1,3 +1,3 @@
export const classNames = (...values: (string | boolean | undefined)[]): string => {
export const classNames = (...values: any[]): string => {
return values.map((value) => (typeof value === 'string' ? value : null)).join(' ')
}

View File

@ -4,14 +4,10 @@ import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { classNames } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Dropdown from '../Dropdown/Dropdown'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
import Modal from '../Shared/Modal'
type Props = {
filesController: FilesController
@ -87,10 +83,43 @@ const PhotoCaptureModal = ({ filesController, close }: Props) => {
close()
}, [capturedPhoto, close, fileName, filesController])
const retryPhoto = () => {
setCapturedPhoto(undefined)
setRecorder(new PhotoRecorder())
}
return (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Take a photo</ModalDialogLabel>
<ModalDialogDescription>
<Modal
title="Take a photo"
close={close}
actions={[
{
label: 'Capture',
onClick: takePhoto,
type: 'primary',
mobileSlot: 'right',
hidden: !!capturedPhoto,
},
{
label: 'Upload',
onClick: savePhoto,
type: 'primary',
mobileSlot: 'right',
hidden: !capturedPhoto,
},
{
label: 'Cancel',
onClick: close,
type: 'cancel',
mobileSlot: 'left',
},
{
label: 'Retry',
onClick: retryPhoto,
type: 'secondary',
},
]}
>
<div className="px-4 py-4">
<div className="mb-4 flex flex-col">
<label className="text-sm font-medium text-neutral">
File name:
@ -133,7 +162,7 @@ const PhotoCaptureModal = ({ filesController, close }: Props) => {
onChange={(value: string) => {
void recorder.setDevice(value)
}}
className={{
classNameOverride={{
wrapper: 'mt-1',
popover: 'z-modal',
}}
@ -141,40 +170,8 @@ const PhotoCaptureModal = ({ filesController, close }: Props) => {
</label>
</div>
)}
</ModalDialogDescription>
<ModalDialogButtons>
{!capturedPhoto && (
<Button
primary
colorStyle="danger"
className="flex items-center gap-2"
onClick={() => {
void takePhoto()
}}
>
<Icon type="camera" />
Take photo
</Button>
)}
{capturedPhoto && (
<div className="flex items-center gap-2">
<Button
className="flex items-center gap-2"
onClick={() => {
setCapturedPhoto(undefined)
setRecorder(new PhotoRecorder())
}}
>
Retry
</Button>
<Button primary className="flex items-center gap-2" onClick={savePhoto}>
<Icon type="upload" />
Upload
</Button>
</div>
)}
</ModalDialogButtons>
</ModalDialog>
</div>
</Modal>
)
}

View File

@ -4,13 +4,9 @@ import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { classNames } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
import Modal from '../Shared/Modal'
type Props = {
filesController: FilesController
@ -77,10 +73,59 @@ const VideoCaptureModal = ({ filesController, close }: Props) => {
return URL.createObjectURL(capturedVideo)
}, [capturedVideo])
const stopRecording = async () => {
const capturedVideo = await recorder.stop()
setIsRecording(false)
setCapturedVideo(capturedVideo)
}
const retryRecording = () => {
setCapturedVideo(undefined)
setRecorder(new VideoRecorder(fileName))
setIsRecorderReady(false)
}
return (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Record a video</ModalDialogLabel>
<ModalDialogDescription>
<Modal
title="Record a video"
close={close}
actions={[
{
label: 'Cancel',
onClick: close,
type: 'cancel',
mobileSlot: 'left',
},
{
label: 'Record',
onClick: startRecording,
type: 'primary',
mobileSlot: 'right',
hidden: !!capturedVideo || isRecording,
},
{
label: 'Stop',
onClick: stopRecording,
type: 'primary',
mobileSlot: 'right',
hidden: !!capturedVideo || !isRecording,
},
{
label: 'Retry',
onClick: retryRecording,
type: 'secondary',
hidden: !capturedVideo,
},
{
label: 'Upload',
onClick: saveVideo,
type: 'primary',
mobileSlot: 'right',
hidden: !capturedVideo,
},
]}
>
<div className="px-4 py-4">
<div className="mb-4 flex flex-col">
<label className="text-sm font-medium text-neutral">
File name:
@ -111,55 +156,8 @@ const VideoCaptureModal = ({ filesController, close }: Props) => {
</div>
)}
</div>
</ModalDialogDescription>
<ModalDialogButtons>
{!capturedVideo && !isRecording && (
<Button
primary
className="flex items-center gap-2"
onClick={() => {
void startRecording()
}}
>
<Icon type="camera" />
Start recording
</Button>
)}
{!capturedVideo && isRecording && (
<Button
primary
colorStyle="danger"
className="flex items-center gap-2"
onClick={async () => {
const capturedVideo = await recorder.stop()
setIsRecording(false)
setCapturedVideo(capturedVideo)
}}
>
<Icon type="camera" />
Stop recording
</Button>
)}
{capturedVideo && (
<div className="flex items-center gap-2">
<Button
className="flex items-center gap-2"
onClick={() => {
setCapturedVideo(undefined)
setRecorder(new VideoRecorder(fileName))
setIsRecorderReady(false)
}}
>
Retry
</Button>
<Button primary className="flex items-center gap-2" onClick={saveVideo}>
<Icon type="upload" />
Upload
</Button>
</div>
)}
</ModalDialogButtons>
</ModalDialog>
</div>
</Modal>
)
}

View File

@ -19,8 +19,10 @@ import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { ChallengeModalValues } from './ChallengeModalValues'
import { InputValue } from './InputValue'
import { isMobileScreen } from '@/Utils'
import { isIOS, isMobileScreen } from '@/Utils'
import { classNames } from '@standardnotes/utils'
import MobileModalAction from '../Shared/MobileModalAction'
import { useModalAnimation } from '../Shared/useModalAnimation'
type Props = {
application: WebApplication
@ -209,98 +211,121 @@ const ChallengeModal: FunctionComponent<Props> = ({
}
}, [application, cancelChallenge, challenge.cancelable])
if (!challenge.prompts) {
const [isMounted, setElement] = useModalAnimation(!!challenge.prompts.length)
if (!isMounted) {
return null
}
const isFullScreenBlocker = challenge.reason === ChallengeReason.ApplicationUnlock
const isMobileOverlay = isMobileScreen() && !isFullScreenBlocker
const contentClasses = classNames(
'challenge-modal relative flex flex-col items-center rounded border-solid border-border p-8 md:border',
!isMobileScreen() && 'shadow-overlay-light',
isMobileOverlay && 'border border-solid border-border shadow-overlay-light',
isFullScreenBlocker && isMobileScreen() ? 'bg-passive-5' : 'bg-default',
)
return (
<DialogOverlay
className={`sn-component ${isFullScreenBlocker ? 'bg-passive-5' : ''}`}
className={`sn-component p-0 ${isFullScreenBlocker ? 'bg-passive-5' : ''}`}
onDismiss={cancelChallenge}
dangerouslyBypassFocusLock={bypassModalFocusLock}
key={challenge.id}
ref={setElement}
>
<DialogContent aria-label="Challenge modal" className={contentClasses}>
<DialogContent
aria-label="Challenge modal"
className={classNames(
'challenge-modal relative m-0 flex h-full w-full flex-col items-center rounded border-solid border-border bg-default p-0 md:h-auto md:w-auto md:border',
!isMobileScreen() && 'shadow-overlay-light',
isMobileOverlay && 'shadow-overlay-light border border-solid border-border',
)}
>
<div
className={classNames(
'w-full border-b border-solid border-border py-1.5 px-1.5 md:hidden',
isIOS() && 'pt-safe-top',
)}
>
<div className="grid w-full grid-cols-[0.35fr_1fr_0.35fr] gap-2">
{challenge.cancelable ? (
<MobileModalAction slot="left" type="cancel" action={cancelChallenge}>
Cancel
</MobileModalAction>
) : (
<div />
)}
<div className="flex items-center justify-center text-base font-semibold text-text">Authenticate</div>
<div />
</div>
</div>
{challenge.cancelable && (
<button
onClick={cancelChallenge}
aria-label="Close modal"
className="absolute top-4 right-4 flex cursor-pointer border-0 bg-transparent p-1"
className="absolute top-4 right-4 hidden cursor-pointer border-0 bg-transparent p-1 md:flex"
>
<Icon type="close" className="text-neutral" />
</button>
)}
<ProtectedIllustration className="mb-4 h-30 w-30" />
<div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div>
{challenge.subheading && (
<div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div>
)}
<form
className="flex min-w-76 flex-col items-center"
onSubmit={(e) => {
e.preventDefault()
submit()
}}
ref={promptsContainerRef}
>
{challenge.prompts.map((prompt, index) => (
<ChallengeModalPrompt
application={application}
key={prompt.id}
prompt={prompt}
values={values}
index={index}
onValueChange={onValueChange}
isInvalid={values[prompt.id].invalid}
/>
))}
</form>
<Button primary disabled={isProcessing} className="mt-1 mb-3.5 min-w-76" onClick={submit}>
{isProcessing ? 'Generating Keys...' : 'Submit'}
</Button>
{shouldShowForgotPasscode && (
<Button
className="flex min-w-76 items-center justify-center"
onClick={() => {
setBypassModalFocusLock(true)
application.alertService
.confirm(
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
'Forgot passcode?',
'Delete local data',
ButtonType.Danger,
)
.then((shouldDeleteLocalData) => {
if (shouldDeleteLocalData) {
application.user.signOut().catch(console.error)
}
})
.catch(console.error)
.finally(() => {
setBypassModalFocusLock(false)
})
<div className="flex min-h-0 w-full flex-grow flex-col items-center overflow-auto p-8">
<ProtectedIllustration className="mb-4 h-30 w-30 flex-shrink-0" />
<div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div>
{challenge.subheading && (
<div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div>
)}
<form
className="flex w-full max-w-76 flex-col items-center md:min-w-76"
onSubmit={(e) => {
e.preventDefault()
submit()
}}
ref={promptsContainerRef}
>
<Icon type="help" className="mr-2 text-neutral" />
Forgot passcode?
{challenge.prompts.map((prompt, index) => (
<ChallengeModalPrompt
application={application}
key={prompt.id}
prompt={prompt}
values={values}
index={index}
onValueChange={onValueChange}
isInvalid={values[prompt.id].invalid}
/>
))}
</form>
<Button primary disabled={isProcessing} className="mt-1 mb-3.5 min-w-76" onClick={submit}>
{isProcessing ? 'Generating Keys...' : 'Submit'}
</Button>
)}
{shouldShowWorkspaceSwitcher && (
<LockscreenWorkspaceSwitcher
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
/>
)}
{shouldShowForgotPasscode && (
<Button
className="flex min-w-76 items-center justify-center"
onClick={() => {
setBypassModalFocusLock(true)
application.alertService
.confirm(
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
'Forgot passcode?',
'Delete local data',
ButtonType.Danger,
)
.then((shouldDeleteLocalData) => {
if (shouldDeleteLocalData) {
application.user.signOut().catch(console.error)
}
})
.catch(console.error)
.finally(() => {
setBypassModalFocusLock(false)
})
}}
>
<Icon type="help" className="mr-2 text-neutral" />
Forgot passcode?
</Button>
)}
{shouldShowWorkspaceSwitcher && (
<LockscreenWorkspaceSwitcher
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
/>
)}
</div>
</DialogContent>
</DialogOverlay>
)

View File

@ -99,14 +99,16 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
return (
<label
key={option.label}
className={`cursor-pointer rounded px-2 py-1.5 focus-within:ring-2 focus-within:ring-info ${
className={`relative flex cursor-pointer items-center justify-center rounded px-2 py-1.5 text-center focus-within:ring-2 focus-within:ring-info ${
selected ? 'bg-default font-semibold text-foreground' : 'text-passive-0 hover:bg-passive-3'
}`}
>
<input
type="radio"
name={`session-duration-${prompt.id}`}
className={'m-0 appearance-none focus:shadow-none focus:outline-none'}
className={
'absolute top-0 left-0 m-0 h-px w-px appearance-none focus:shadow-none focus:outline-none'
}
style={{
marginRight: 0,
}}

View File

@ -13,6 +13,7 @@ import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/Premium
import { SuperNoteImporter } from '../NoteView/SuperEditor/SuperNoteImporter'
import MenuRadioButtonItem from '../Menu/MenuRadioButtonItem'
import { Pill } from '../Preferences/PreferencesComponents/Content'
import ModalOverlay from '../Shared/ModalOverlay'
type ChangeEditorMenuProps = {
application: WebApplication
@ -174,6 +175,8 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
closeMenu()
}, [note, pendingSuperItem, selectNonComponent, closeMenu])
const closeSuperNoteImporter = () => setShowSuperImporter(false)
return (
<>
<Menu className="pt-0.5 pb-1" a11yLabel="Change note type menu" isOpen={isVisible}>
@ -220,14 +223,16 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
)
})}
</Menu>
{showSuperImporter && note && (
<SuperNoteImporter
note={note}
application={application}
onConvertComplete={handleSuperNoteConversionCompletion}
closeDialog={() => setShowSuperImporter(false)}
/>
)}
<ModalOverlay isOpen={showSuperImporter} onDismiss={closeSuperNoteImporter}>
{note && (
<SuperNoteImporter
note={note}
application={application}
onConvertComplete={handleSuperNoteConversionCompletion}
closeDialog={closeSuperNoteImporter}
/>
)}
</ModalOverlay>
</>
)
}

View File

@ -42,6 +42,7 @@ import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/u
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
import { PaneController } from '@/Controllers/PaneController/PaneController'
import EmptyFilesView from './EmptyFilesView'
import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
type Props = {
accountMenuController: AccountMenuController
@ -86,7 +87,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
},
ref,
) => {
const { toggleAppPane, panes } = useResponsiveAppPane()
const { setPaneLayout, panes } = useResponsiveAppPane()
const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController
const { selected: selectedTag, selectedAsTag } = navigationController
const {
@ -185,9 +186,9 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
void filesController.selectAndUploadNewFiles()
} else {
await createNewNote()
toggleAppPane(AppPaneId.Editor)
setPaneLayout(PaneLayout.Editing)
}
}, [isFilesSmartView, filesController, createNewNote, toggleAppPane, application])
}, [isFilesSmartView, application, filesController, createNewNote, setPaneLayout])
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const shouldUseTableView = (isFilesSmartView || isTableViewEnabled) && !isMobileScreen

View File

@ -4,6 +4,7 @@ import Icon from '@/Components/Icon/Icon'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
import Popover from '@/Components/Popover/Popover'
import ModalOverlay from '@/Components/Shared/ModalOverlay'
import { FilesController } from '@/Controllers/FilesController'
import { PhotoRecorder } from '@/Controllers/Moments/PhotoRecorder'
import { classNames } from '@standardnotes/snjs'
@ -40,6 +41,10 @@ const AddItemMenuButton = ({
const canShowMenu = isInFilesSmartView && deviceHasCamera
const closeCaptureModal = () => {
setCaptureType(undefined)
}
return (
<>
<button
@ -101,22 +106,12 @@ const AddItemMenuButton = ({
</MenuItem>
</Menu>
</Popover>
{captureType === 'photo' && (
<PhotoCaptureModal
filesController={filesController}
close={() => {
setCaptureType(undefined)
}}
/>
)}
{captureType === 'video' && (
<VideoCaptureModal
filesController={filesController}
close={() => {
setCaptureType(undefined)
}}
/>
)}
<ModalOverlay isOpen={captureType === 'photo'} onDismiss={closeCaptureModal}>
<PhotoCaptureModal filesController={filesController} close={closeCaptureModal} />
</ModalOverlay>
<ModalOverlay isOpen={captureType === 'video'} onDismiss={closeCaptureModal}>
<VideoCaptureModal filesController={filesController} close={closeCaptureModal} />
</ModalOverlay>
</>
)
}

View File

@ -15,7 +15,7 @@ type DropdownProps = {
value: string
onChange: (value: string, item: DropdownItem) => void
disabled?: boolean
className?: {
classNameOverride?: {
wrapper?: string
button?: string
popover?: string
@ -56,7 +56,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
value,
onChange,
disabled,
className,
classNameOverride = {},
fullWidth,
portal = true,
}) => {
@ -68,16 +68,12 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
onChange(value, selectedItem)
}
const wrapperClassName = className?.wrapper ?? ''
const buttonClassName = className?.button ?? ''
const popoverClassName = className?.popover ?? ''
return (
<div className={wrapperClassName}>
<div className={classNameOverride.wrapper}>
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
<StyledListboxButton
className={classNames('w-full', !fullWidth && 'md:w-fit', buttonClassName)}
className={classNames('w-full', !fullWidth && 'md:w-fit', classNameOverride.button)}
children={({ value, label, isExpanded }) => {
const current = items.find((item) => item.value === value)
const icon = current ? current?.icon : null
@ -91,7 +87,10 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
})
}}
/>
<ListboxPopover portal={portal} className={classNames('sn-dropdown sn-dropdown-popover', popoverClassName)}>
<ListboxPopover
portal={portal}
className={classNames('sn-dropdown sn-dropdown-popover', classNameOverride.popover)}
>
<div className="sn-component">
<ListboxList>
{items.map((item) => (

View File

@ -1,6 +1,15 @@
import { WebApplication } from '@/Application/Application'
import { DialogContent, DialogOverlay } from '@reach/dialog'
import { FunctionComponent, KeyboardEventHandler, useCallback, useMemo, useRef, useState } from 'react'
import {
ForwardedRef,
forwardRef,
FunctionComponent,
KeyboardEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react'
import { getFileIconComponent } from './getFileIconComponent'
import Icon from '@/Components/Icon/Icon'
import FilePreviewInfoPanel from './FilePreviewInfoPanel'
@ -17,232 +26,248 @@ import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContaine
import StyledTooltip from '../StyledTooltip/StyledTooltip'
import DecoratedInput from '../Input/DecoratedInput'
import { mergeRefs } from '@/Hooks/mergeRefs'
import { useModalAnimation } from '../Shared/useModalAnimation'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const FilePreviewModal: FunctionComponent<Props> = observer(({ application, viewControllerManager }) => {
const { currentFile, setCurrentFile, otherFiles, dismiss } = viewControllerManager.filePreviewModalController
const FilePreviewModal = observer(
forwardRef(({ application, viewControllerManager }: Props, ref: ForwardedRef<HTMLDivElement>) => {
const { currentFile, setCurrentFile, otherFiles, dismiss } = viewControllerManager.filePreviewModalController
if (!currentFile) {
return null
}
const [isRenaming, setIsRenaming] = useState(false)
const renameInputRef = useRef<HTMLInputElement>(null)
const [showLinkedBubblesContainer, setShowLinkedBubblesContainer] = useState(false)
const [showOptionsMenu, setShowOptionsMenu] = useState(false)
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const [isRenaming, setIsRenaming] = useState(false)
const renameInputRef = useRef<HTMLInputElement>(null)
const [showLinkedBubblesContainer, setShowLinkedBubblesContainer] = useState(false)
const [showOptionsMenu, setShowOptionsMenu] = useState(false)
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const keyDownHandler: KeyboardEventHandler = useCallback(
(event) => {
const KeysToHandle: string[] = [KeyboardKey.Left, KeyboardKey.Right, KeyboardKey.Escape]
if (!KeysToHandle.includes(event.key) || event.target === renameInputRef.current) {
return
}
event.preventDefault()
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
switch (event.key) {
case KeyboardKey.Left: {
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
const previousFile = otherFiles[previousFileIndex]
if (previousFile) {
setCurrentFile(previousFile)
}
break
const keyDownHandler: KeyboardEventHandler = useCallback(
(event) => {
if (!currentFile) {
return null
}
case KeyboardKey.Right: {
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
const nextFile = otherFiles[nextFileIndex]
if (nextFile) {
setCurrentFile(nextFile)
}
break
const KeysToHandle: string[] = [KeyboardKey.Left, KeyboardKey.Right, KeyboardKey.Escape]
if (!KeysToHandle.includes(event.key) || event.target === renameInputRef.current) {
return
}
case KeyboardKey.Escape:
dismiss()
break
event.preventDefault()
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
switch (event.key) {
case KeyboardKey.Left: {
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
const previousFile = otherFiles[previousFileIndex]
if (previousFile) {
setCurrentFile(previousFile)
}
break
}
case KeyboardKey.Right: {
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
const nextFile = otherFiles[nextFileIndex]
if (nextFile) {
setCurrentFile(nextFile)
}
break
}
case KeyboardKey.Escape:
dismiss()
break
}
},
[currentFile, dismiss, otherFiles, setCurrentFile],
)
const IconComponent = useMemo(() => {
return currentFile
? getFileIconComponent(getIconForFileType(currentFile.mimeType), 'w-6 h-6 flex-shrink-0')
: null
}, [currentFile])
const focusInputOnMount = useCallback((input: HTMLInputElement | null) => {
if (input) {
input.focus()
}
},
[currentFile.uuid, dismiss, otherFiles, setCurrentFile],
)
}, [])
const IconComponent = useMemo(
() => getFileIconComponent(getIconForFileType(currentFile.mimeType), 'w-6 h-6 flex-shrink-0'),
[currentFile.mimeType],
)
const focusInputOnMount = useCallback((input: HTMLInputElement | null) => {
if (input) {
input.focus()
}
}, [])
const handleRename = useCallback(async () => {
if (renameInputRef.current) {
const newName = renameInputRef.current.value
if (newName && newName !== currentFile.name) {
await application.items.renameFile(currentFile, newName)
const handleRename = useCallback(async () => {
if (!currentFile) {
return null
}
if (renameInputRef.current) {
const newName = renameInputRef.current.value
if (newName && newName !== currentFile.name) {
await application.items.renameFile(currentFile, newName)
setIsRenaming(false)
setCurrentFile(application.items.findSureItem(currentFile.uuid))
return
}
setIsRenaming(false)
setCurrentFile(application.items.findSureItem(currentFile.uuid))
return
}
setIsRenaming(false)
}
}, [application.items, currentFile, setCurrentFile])
}, [application.items, currentFile, setCurrentFile])
return (
<DialogOverlay
className="sn-component"
aria-label="File preview modal"
onDismiss={dismiss}
initialFocusRef={closeButtonRef}
dangerouslyBypassScrollLock
>
<DialogContent
if (!currentFile) {
return null
}
return (
<DialogOverlay
className="sn-component p-0"
aria-label="File preview modal"
className="flex min-h-[90%] min-w-[90%] flex-col rounded bg-[color:var(--modal-background-color)] p-0 shadow-main "
onDismiss={dismiss}
initialFocusRef={closeButtonRef}
dangerouslyBypassScrollLock
ref={ref}
>
<div
className="flex h-full w-full flex-col focus:shadow-none focus:outline-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
onKeyDown={keyDownHandler}
<DialogContent
aria-label="File preview modal"
className="m-0 flex h-full min-h-[90%] w-full min-w-[90%] flex-col rounded bg-[color:var(--modal-background-color)] p-0 shadow-main "
>
<div className="min-h-6 flex flex-shrink-0 flex-wrap items-center justify-between gap-2 border-0 border-b border-solid border-border px-4 py-3 focus:shadow-none">
<div className="flex items-center">
<div className="h-6 w-6">{IconComponent}</div>
{isRenaming ? (
<DecoratedInput
defaultValue={currentFile.name}
className={{ container: 'ml-3', input: 'p-1', right: 'items-stretch !p-0' }}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
void handleRename()
}
}}
right={[
<button
className="flex h-full items-center justify-center border-l border-border px-2 py-1.5 text-neutral hover:bg-passive-4"
title="Submit"
onClick={handleRename}
>
<Icon type="check" size="small" />
</button>,
]}
ref={mergeRefs([renameInputRef, focusInputOnMount])}
/>
) : (
<span className="ml-3 font-medium">{currentFile.name}</span>
)}
</div>
<div className="flex items-center">
<StyledTooltip label="Rename file" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setIsRenaming((isRenaming) => !isRenaming)}
aria-label="Rename file"
>
<Icon type="pencil-filled" className="text-neutral" />
</button>
</StyledTooltip>
<StyledTooltip label="Show linked items" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setShowLinkedBubblesContainer((show) => !show)}
aria-label="Show linked items"
>
<Icon type="link" className="text-neutral" />
</button>
</StyledTooltip>
<StyledTooltip label="Show file options" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setShowOptionsMenu((show) => !show)}
ref={menuButtonRef}
aria-label="Show file options"
>
<Icon type="more" className="text-neutral" />
</button>
</StyledTooltip>
<Popover
title="File options"
open={showOptionsMenu}
anchorElement={menuButtonRef.current}
togglePopover={() => {
setShowOptionsMenu(false)
}}
side="bottom"
align="start"
className="py-2"
overrideZIndex="z-modal"
>
<Menu a11yLabel="File context menu" isOpen={showOptionsMenu}>
<FileMenuOptions
filesController={viewControllerManager.filesController}
linkingController={viewControllerManager.linkingController}
navigationController={viewControllerManager.navigationController}
selectedFiles={[currentFile]}
closeMenu={() => {
setShowOptionsMenu(false)
<div
className="flex h-full w-full flex-col focus:shadow-none focus:outline-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
onKeyDown={keyDownHandler}
>
<div className="min-h-6 flex flex-shrink-0 flex-wrap items-center justify-between gap-2 border-0 border-b border-solid border-border px-4 py-3 focus:shadow-none">
<div className="flex items-center">
<div className="h-6 w-6">{IconComponent}</div>
{isRenaming ? (
<DecoratedInput
defaultValue={currentFile.name}
className={{ container: 'ml-3', input: 'p-1', right: 'items-stretch !p-0' }}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
void handleRename()
}
}}
shouldShowRenameOption={false}
shouldShowAttachOption={false}
right={[
<button
className="flex h-full items-center justify-center border-l border-border px-2 py-1.5 text-neutral hover:bg-passive-4"
title="Submit"
onClick={handleRename}
>
<Icon type="check" size="small" />
</button>,
]}
ref={mergeRefs([renameInputRef, focusInputOnMount])}
/>
</Menu>
</Popover>
<StyledTooltip label="Show file info" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setShowFileInfoPanel((show) => !show)}
aria-label="Show file info"
) : (
<span className="ml-3 font-medium">{currentFile.name}</span>
)}
</div>
<div className="flex items-center">
<StyledTooltip label="Rename file" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setIsRenaming((isRenaming) => !isRenaming)}
aria-label="Rename file"
>
<Icon type="pencil-filled" className="text-neutral" />
</button>
</StyledTooltip>
<StyledTooltip label="Show linked items" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setShowLinkedBubblesContainer((show) => !show)}
aria-label="Show linked items"
>
<Icon type="link" className="text-neutral" />
</button>
</StyledTooltip>
<StyledTooltip label="Show file options" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setShowOptionsMenu((show) => !show)}
ref={menuButtonRef}
aria-label="Show file options"
>
<Icon type="more" className="text-neutral" />
</button>
</StyledTooltip>
<Popover
title="File options"
open={showOptionsMenu}
anchorElement={menuButtonRef.current}
togglePopover={() => {
setShowOptionsMenu(false)
}}
side="bottom"
align="start"
className="py-2"
overrideZIndex="z-modal"
>
<Icon type="info" className="text-neutral" />
<Menu a11yLabel="File context menu" isOpen={showOptionsMenu}>
<FileMenuOptions
filesController={viewControllerManager.filesController}
linkingController={viewControllerManager.linkingController}
navigationController={viewControllerManager.navigationController}
selectedFiles={[currentFile]}
closeMenu={() => {
setShowOptionsMenu(false)
}}
shouldShowRenameOption={false}
shouldShowAttachOption={false}
/>
</Menu>
</Popover>
<StyledTooltip label="Show file info" className="!z-modal">
<button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
onClick={() => setShowFileInfoPanel((show) => !show)}
aria-label="Show file info"
>
<Icon type="info" className="text-neutral" />
</button>
</StyledTooltip>
<button
ref={closeButtonRef}
onClick={dismiss}
aria-label="Close modal"
className="flex cursor-pointer rounded border-0 bg-transparent p-1 hover:bg-contrast"
>
<Icon type="close" className="text-neutral" />
</button>
</StyledTooltip>
<button
ref={closeButtonRef}
onClick={dismiss}
aria-label="Close modal"
className="flex cursor-pointer rounded border-0 bg-transparent p-1 hover:bg-contrast"
>
<Icon type="close" className="text-neutral" />
</button>
</div>
</div>
{showLinkedBubblesContainer && (
<div className="-mt-1 min-h-0 flex-shrink-0 border-b border-border py-1.5 px-3.5">
<LinkedItemBubblesContainer
linkingController={viewControllerManager.linkingController}
item={currentFile}
/>
</div>
)}
<div className="flex min-h-0 flex-grow">
<div className="relative flex max-w-full flex-grow items-center justify-center">
<FilePreview file={currentFile} application={application} key={currentFile.uuid} />
</div>
{showFileInfoPanel && <FilePreviewInfoPanel file={currentFile} />}
</div>
</div>
{showLinkedBubblesContainer && (
<div className="-mt-1 min-h-0 flex-shrink-0 border-b border-border py-1.5 px-3.5">
<LinkedItemBubblesContainer
linkingController={viewControllerManager.linkingController}
item={currentFile}
/>
</div>
)}
<div className="flex min-h-0 flex-grow">
<div className="relative flex max-w-full flex-grow items-center justify-center">
<FilePreview file={currentFile} application={application} key={currentFile.uuid} />
</div>
{showFileInfoPanel && <FilePreviewInfoPanel file={currentFile} />}
</div>
</div>
</DialogContent>
</DialogOverlay>
)
})
</DialogContent>
</DialogOverlay>
)
}),
)
FilePreviewModal.displayName = 'FilePreviewModal'
const FilePreviewModalWrapper: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
return viewControllerManager.filePreviewModalController.isOpen ? (
<FilePreviewModal application={application} viewControllerManager={viewControllerManager} />
) : null
const [isMounted, setElement] = useModalAnimation(viewControllerManager.filePreviewModalController.isOpen)
if (!isMounted) {
return null
}
return <FilePreviewModal application={application} viewControllerManager={viewControllerManager} ref={setElement} />
}
export default observer(FilePreviewModalWrapper)

View File

@ -2,17 +2,14 @@ import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { ContentType, DecryptedTransferPayload, pluralize, SNTag, TagContent, UuidGenerator } from '@standardnotes/snjs'
import { Importer } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite'
import { useCallback, useReducer, useState } from 'react'
import { useCallback, useMemo, useReducer, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import Button from '../Button/Button'
import { useStateRef } from '@/Hooks/useStateRef'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
import { ImportModalFileItem } from './ImportModalFileItem'
import ImportModalInitialPage from './InitialPage'
import { ImportModalAction, ImportModalFile, ImportModalState } from './Types'
import Modal, { ModalAction } from '../Shared/Modal'
import ModalOverlay from '../Shared/ModalOverlay'
const reducer = (state: ImportModalState, action: ImportModalAction): ImportModalState => {
switch (action.type) {
@ -190,36 +187,44 @@ const ImportModal = ({ viewControllerManager }: { viewControllerManager: ViewCon
})
}, [state.importTag, viewControllerManager.isImportModalVisible, viewControllerManager.navigationController])
if (!viewControllerManager.isImportModalVisible.get()) {
return null
}
const isReadyToImport = files.length > 0 && files.every((file) => file.status === 'ready')
const importSuccessOrError =
files.length > 0 && files.every((file) => file.status === 'success' || file.status === 'error')
const modalActions: ModalAction[] = useMemo(
() => [
{
label: 'Import',
type: 'primary',
onClick: parseAndImport,
hidden: !isReadyToImport,
mobileSlot: 'right',
},
{
label: importSuccessOrError ? 'Close' : 'Cancel',
type: 'cancel',
onClick: closeDialog,
mobileSlot: 'left',
},
],
[closeDialog, importSuccessOrError, isReadyToImport, parseAndImport],
)
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeDialog}>Import</ModalDialogLabel>
<ModalDialogDescription>
{!files.length && <ImportModalInitialPage dispatch={dispatch} />}
{files.length > 0 && (
<div className="divide-y divide-border">
{files.map((file) => (
<ImportModalFileItem file={file} key={file.id} dispatch={dispatch} importer={importer} />
))}
</div>
)}
</ModalDialogDescription>
<ModalDialogButtons>
{files.length > 0 && files.every((file) => file.status === 'ready') && (
<Button primary onClick={parseAndImport}>
Import
</Button>
)}
<Button onClick={closeDialog}>
{files.length > 0 && files.every((file) => file.status === 'success' || file.status === 'error')
? 'Close'
: 'Cancel'}
</Button>
</ModalDialogButtons>
</ModalDialog>
<ModalOverlay isOpen={viewControllerManager.isImportModalVisible.get()} onDismiss={closeDialog}>
<Modal title="Import" close={closeDialog} actions={modalActions}>
<div className="px-4 py-4">
{!files.length && <ImportModalInitialPage dispatch={dispatch} />}
{files.length > 0 && (
<div className="divide-y divide-border">
{files.map((file) => (
<ImportModalFileItem file={file} key={file.id} dispatch={dispatch} importer={importer} />
))}
</div>
)}
</div>
</Modal>
</ModalOverlay>
)
}

View File

@ -40,6 +40,7 @@ import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
import ModalOverlay from '@/Components/Shared/ModalOverlay'
const NotePreviewCharLimit = 160
@ -205,7 +206,9 @@ export const SuperEditor: FunctionComponent<Props> = ({
</BlocksEditorComposer>
</FilesControllerProvider>
</LinkingControllerProvider>
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
<ModalOverlay isOpen={showMarkdownPreview} onDismiss={closeMarkdownPreview}>
<SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />
</ModalOverlay>
</ErrorBoundary>
</div>
)

View File

@ -1,16 +1,12 @@
import { WebApplication } from '@/Application/Application'
import { NoteType, SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Button from '@/Components/Button/Button'
import ImportPlugin from './Plugins/ImportPlugin/ImportPlugin'
import { NoteViewController } from '../Controller/NoteViewController'
import { spaceSeparatedStrings } from '@standardnotes/utils'
import Modal, { ModalAction } from '@/Components/Shared/Modal'
const NotePreviewCharLimit = 160
@ -82,51 +78,55 @@ export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application,
onConvertComplete()
}, [closeDialog, application, note, onConvertComplete, performConvert])
const modalActions: ModalAction[] = useMemo(
() => [
{
label: 'Cancel',
onClick: closeDialog,
type: 'cancel',
mobileSlot: 'left',
},
{
label: 'Convert',
onClick: confirmConvert,
mobileSlot: 'right',
type: 'primary',
},
{
label: 'Convert As-Is',
onClick: convertAsIs,
type: 'secondary',
},
],
[closeDialog, confirmConvert, convertAsIs],
)
if (isSeamlessConvert) {
return null
}
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeDialog}>
Convert to Super note
<p className="text-sm font-normal text-neutral">
The following is a preview of how your note will look when converted to Super. Super notes use a custom format
under the hood. Converting your note will transition it from plaintext to the custom Super format.
</p>
</ModalDialogLabel>
<ModalDialogDescription>
<div className="relative w-full">
<ErrorBoundary>
<BlocksEditorComposer readonly initialValue={undefined}>
<BlocksEditor
readonly
onChange={handleChange}
ignoreFirstChange={false}
className="relative resize-none text-base focus:shadow-none focus:outline-none"
previewLength={NotePreviewCharLimit}
spellcheck={note.spellcheck}
>
<ImportPlugin text={note.text} format={format} />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<div className="flex w-full justify-between">
<div>
<Button onClick={convertAsIs}>Convert As-Is</Button>
</div>
<div className="flex">
<Button onClick={closeDialog}>Cancel</Button>
<div className="min-w-3" />
<Button primary onClick={confirmConvert}>
Convert to Super
</Button>
</div>
</div>
</ModalDialogButtons>
</ModalDialog>
<Modal title="Convert to Super note" close={closeDialog} actions={modalActions}>
<div className="border-b border-border px-4 py-4 text-sm font-normal text-neutral md:py-3">
The following is a preview of how your note will look when converted to Super. Super notes use a custom format
under the hood. Converting your note will transition it from plaintext to the custom Super format.
</div>
<div className="relative w-full px-4 py-4">
<ErrorBoundary>
<BlocksEditorComposer readonly initialValue={undefined}>
<BlocksEditor
readonly
onChange={handleChange}
ignoreFirstChange={false}
className="relative resize-none text-base focus:shadow-none focus:outline-none"
previewLength={NotePreviewCharLimit}
spellcheck={note.spellcheck}
>
<ImportPlugin text={note.text} format={format} />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</div>
</Modal>
)
}

View File

@ -1,16 +1,12 @@
import { SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useState } from 'react'
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Button from '@/Components/Button/Button'
import MarkdownPreviewPlugin from './Plugins/MarkdownPreviewPlugin/MarkdownPreviewPlugin'
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
import { copyTextToClipboard } from '../../../Utils/copyTextToClipboard'
import Modal, { ModalAction } from '@/Components/Shared/Modal'
type Props = {
note: SNNote
@ -33,33 +29,33 @@ export const SuperNoteMarkdownPreview: FunctionComponent<Props> = ({ note, close
setMarkdown(markdown)
}, [])
const modalActions: ModalAction[] = useMemo(
() => [
{
label: didCopy ? 'Copied' : 'Copy',
type: 'primary',
onClick: copy,
mobileSlot: 'left',
},
],
[copy, didCopy],
)
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeDialog}>Markdown Preview</ModalDialogLabel>
<ModalDialogDescription>
<div className="relative w-full">
<ErrorBoundary>
<BlocksEditorComposer readonly initialValue={note.text} nodes={[FileNode, BubbleNode]}>
<BlocksEditor
readonly
className="relative resize-none text-base focus:shadow-none focus:outline-none"
spellcheck={note.spellcheck}
>
<MarkdownPreviewPlugin onMarkdown={onMarkdown} />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<div className="flex">
<Button onClick={closeDialog}>Close</Button>
<div className="min-w-3" />
<Button primary onClick={copy}>
{didCopy ? 'Copied' : 'Copy'}
</Button>
</div>
</ModalDialogButtons>
</ModalDialog>
<Modal title="Markdown Preview" close={closeDialog} actions={modalActions}>
<div className="relative w-full px-4 py-4">
<ErrorBoundary>
<BlocksEditorComposer readonly initialValue={note.text} nodes={[FileNode, BubbleNode]}>
<BlocksEditor
readonly
className="relative resize-none text-base focus:shadow-none focus:outline-none"
spellcheck={note.spellcheck}
>
<MarkdownPreviewPlugin onMarkdown={onMarkdown} />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</div>
</Modal>
)
}

View File

@ -1,12 +1,10 @@
import { WebApplication } from '@/Application/Application'
import { createRef } from 'react'
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
import Button from '@/Components/Button/Button'
import DecoratedPasswordInput from '../Input/DecoratedPasswordInput'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import Modal from '../Shared/Modal'
import { isMobileScreen } from '@/Utils'
import Spinner from '../Spinner/Spinner'
interface Props {
application: WebApplication
@ -25,6 +23,8 @@ type State = {
}
const DEFAULT_CONTINUE_TITLE = 'Continue'
const GENERATING_CONTINUE_TITLE = 'Generating Keys...'
const FINISH_CONTINUE_TITLE = 'Finish'
enum Steps {
PasswordStep = 1,
@ -89,7 +89,7 @@ class PasswordWizard extends AbstractComponent<Props, State> {
this.setState({
isContinuing: true,
showSpinner: true,
continueTitle: 'Generating Keys...',
continueTitle: GENERATING_CONTINUE_TITLE,
})
const valid = await this.validateCurrentPassword()
@ -107,7 +107,7 @@ class PasswordWizard extends AbstractComponent<Props, State> {
this.setState({
isContinuing: false,
showSpinner: false,
continueTitle: 'Finish',
continueTitle: FINISH_CONTINUE_TITLE,
step: Steps.FinishStep,
})
}
@ -228,10 +228,32 @@ class PasswordWizard extends AbstractComponent<Props, State> {
override render() {
return (
<div className="sn-component" id="password-wizard">
<ModalDialog>
<ModalDialogLabel closeDialog={this.dismiss}>{this.state.title}</ModalDialogLabel>
<ModalDialogDescription>
<div className="sn-component h-full w-full md:h-auto md:w-auto" id="password-wizard">
<Modal
title={this.state.title}
close={this.dismiss}
actions={[
{
label: 'Cancel',
onClick: this.dismiss,
type: 'cancel',
mobileSlot: 'left',
},
{
label:
this.state.continueTitle === GENERATING_CONTINUE_TITLE && isMobileScreen() ? (
<Spinner className="h-4 w-4" />
) : (
this.state.continueTitle
),
onClick: this.nextStep,
type: 'primary',
mobileSlot: 'right',
disabled: this.state.lockContinue,
},
]}
>
<div className="px-4 py-4">
{this.state.step === Steps.PasswordStep && (
<div className="flex flex-col pb-1.5">
<form>
@ -284,13 +306,8 @@ class PasswordWizard extends AbstractComponent<Props, State> {
</p>
</div>
)}
</ModalDialogDescription>
<ModalDialogButtons>
<Button primary onClick={this.nextStep} disabled={this.state.lockContinue} className="min-w-20">
{this.state.continueTitle}
</Button>
</ModalDialogButtons>
</ModalDialog>
</div>
</Modal>
</div>
)
}

View File

@ -1,10 +1,8 @@
import { SNComponent } from '@standardnotes/snjs'
import { useCallback } from 'react'
import Button from '@/Components/Button/Button'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import Modal from '../Shared/Modal'
type Props = {
callback: (approved: boolean) => void
@ -25,9 +23,30 @@ const PermissionsModal = ({ callback, component, dismiss, permissionsString }: P
}, [callback, dismiss])
return (
<ModalDialog className="w-full md:!w-[350px]">
<ModalDialogLabel closeDialog={deny}>Activate Component</ModalDialogLabel>
<ModalDialogDescription>
<Modal
title="Activate Component"
close={deny}
actions={[
{ label: 'Cancel', onClick: deny, type: 'cancel', mobileSlot: 'left' },
{
label: 'Continue',
onClick: accept,
type: 'primary',
mobileSlot: 'right',
},
]}
className={{ content: 'md:!w-[350px]' }}
customFooter={
<div className="hidden md:block">
<ModalDialogButtons>
<Button primary fullWidth onClick={accept} className="block">
Continue
</Button>
</ModalDialogButtons>
</div>
}
>
<div className="px-4 py-4">
<div className="text-base">
<strong>{component.displayName}</strong>
{' would like to interact with your '}
@ -41,13 +60,8 @@ const PermissionsModal = ({ callback, component, dismiss, permissionsString }: P
</a>
</p>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button primary fullWidth onClick={accept} className="block">
Continue
</Button>
</ModalDialogButtons>
</ModalDialog>
</div>
</Modal>
)
}

View File

@ -1,6 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { ApplicationEvent, PermissionDialog } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import ModalOverlay from '../Shared/ModalOverlay'
import PermissionsModal from './PermissionsModal'
type Props = {
@ -42,14 +43,18 @@ const PermissionsModalWrapper: FunctionComponent<Props> = ({ application }) => {
}
}, [application, onAppStart])
return dialog ? (
<PermissionsModal
callback={dialog.callback}
dismiss={dismissPermissionsDialog}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>
) : null
return (
<ModalOverlay isOpen={!!dialog}>
{dialog && (
<PermissionsModal
callback={dialog.callback}
dismiss={dismissPermissionsDialog}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>
)}
</ModalOverlay>
)
}
export default PermissionsModalWrapper

View File

@ -1,8 +1,14 @@
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
import { classNames } from '@standardnotes/snjs'
import { ReactNode } from 'react'
import Portal from '../Portal/Portal'
import { useModalAnimation } from '../Shared/useModalAnimation'
const DisableScroll = () => {
useDisableBodyScrollOnMobile()
return null
}
const MobilePopoverContent = ({
open,
@ -17,54 +23,7 @@ const MobilePopoverContent = ({
title: string
className?: string
}) => {
const [isMounted, setPopoverElement] = useLifecycleAnimation({
open,
enter: {
keyframes: [
{
opacity: 0.25,
transform: 'translateY(1rem)',
},
{
opacity: 1,
transform: 'translateY(0)',
},
],
options: {
easing: 'cubic-bezier(.36,.66,.04,1)',
duration: 150,
fill: 'forwards',
},
initialStyle: {
transformOrigin: 'bottom',
},
},
enterCallback: (element) => {
element.scrollTop = 0
},
exit: {
keyframes: [
{
opacity: 1,
transform: 'translateY(0)',
},
{
opacity: 0,
transform: 'translateY(1rem)',
},
],
options: {
easing: 'cubic-bezier(.36,.66,.04,1)',
duration: 150,
fill: 'forwards',
},
initialStyle: {
transformOrigin: 'bottom',
},
},
})
useDisableBodyScrollOnMobile()
const [isMounted, setPopoverElement] = useModalAnimation(open)
if (!isMounted) {
return null
@ -72,14 +31,15 @@ const MobilePopoverContent = ({
return (
<Portal>
<DisableScroll />
<div
ref={setPopoverElement}
className="absolute top-0 left-0 z-modal flex h-full w-full origin-bottom flex-col bg-default pt-safe-top pb-safe-bottom opacity-0"
className="fixed top-0 left-0 z-modal flex h-full w-full flex-col bg-default pt-safe-top pb-safe-bottom"
>
<div className="flex items-center justify-between border-b border-border py-2.5 px-3 text-base">
<div />
<div className="font-semibold">{title}</div>
<button className="font-semibold active:shadow-none active:outline-none" onClick={requestClose}>
<button className="font-semibold text-info active:shadow-none active:outline-none" onClick={requestClose}>
Done
</button>
</div>

View File

@ -45,6 +45,7 @@ const Popover = ({
disableClickOutside,
disableMobileFullscreenTakeover,
maxHeight,
portal,
}: Props) => {
const popoverId = useRef(UuidGenerator.GenerateUuid())
@ -123,6 +124,7 @@ const Popover = ({
side={side}
title={title}
togglePopover={togglePopover}
portal={portal}
>
{children}
</PositionedPopoverContent>

View File

@ -2,9 +2,7 @@ import { useDocumentRect } from '@/Hooks/useDocumentRect'
import { useAutoElementRect } from '@/Hooks/useElementRect'
import { classNames } from '@standardnotes/utils'
import { useCallback, useLayoutEffect, useState } from 'react'
import Icon from '../Icon/Icon'
import Portal from '../Portal/Portal'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { getPositionedPopoverStyles } from './GetPositionedPopoverStyles'
import { PopoverContentProps } from './Types'
import { usePopoverCloseOnClickOutside } from './Utils/usePopoverCloseOnClickOutside'
@ -25,6 +23,7 @@ const PositionedPopoverContent = ({
disableClickOutside,
disableMobileFullscreenTakeover,
maxHeight,
portal = true,
}: PopoverContentProps) => {
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
const popoverRect = useAutoElementRect(popoverElement)
@ -72,7 +71,7 @@ const PositionedPopoverContent = ({
}, [popoverElement, correctInitialScrollForOverflowedContent])
return (
<Portal>
<Portal disabled={!portal}>
<div
className={classNames(
'absolute top-0 left-0 flex w-full min-w-80 cursor-auto flex-col',
@ -81,6 +80,7 @@ const PositionedPopoverContent = ({
overrideZIndex ? overrideZIndex : 'z-dropdown-menu',
!isDesktopScreen && !disableMobileFullscreenTakeover ? 'pt-safe-top pb-safe-bottom' : '',
isDesktopScreen || disableMobileFullscreenTakeover ? 'invisible' : '',
className,
)}
style={{
...styles,
@ -97,15 +97,7 @@ const PositionedPopoverContent = ({
}
}}
>
<div className={classNames(disableMobileFullscreenTakeover && 'hidden', 'md:hidden')}>
<div className="flex items-center justify-end px-3 pt-2">
<button className="rounded-full border border-border p-1" onClick={togglePopover}>
<Icon type="close" className="h-6 w-6" />
</button>
</div>
<HorizontalSeparator classes="my-2" />
</div>
<div className={className}>{children}</div>
{children}
</div>
</Portal>
)

View File

@ -30,16 +30,6 @@ type PopoverAnchorPointProps = {
anchorElement?: never
}
type PopoverMutuallyExclusiveProps =
| {
togglePopover: () => void
disableMobileFullscreenTakeover?: never
}
| {
togglePopover?: never
disableMobileFullscreenTakeover: boolean
}
type CommonPopoverProps = {
align?: PopoverAlignment
children: ReactNode
@ -48,7 +38,10 @@ type CommonPopoverProps = {
className?: string
disableClickOutside?: boolean
maxHeight?: (calculatedMaxHeight: number) => number
togglePopover?: () => void
disableMobileFullscreenTakeover?: boolean
title: string
portal?: boolean
}
export type PopoverContentProps = CommonPopoverProps & {
@ -61,5 +54,5 @@ export type PopoverContentProps = CommonPopoverProps & {
}
export type PopoverProps =
| (CommonPopoverProps & PopoverMutuallyExclusiveProps & PopoverAnchorElementProps)
| (CommonPopoverProps & PopoverMutuallyExclusiveProps & PopoverAnchorPointProps)
| (CommonPopoverProps & PopoverAnchorElementProps)
| (CommonPopoverProps & PopoverAnchorPointProps)

View File

@ -1,4 +1,3 @@
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { useEffect } from 'react'
type Options = {
@ -18,19 +17,14 @@ export const usePopoverCloseOnClickOutside = ({
}: Options) => {
useEffect(() => {
const closeIfClickedOutside = (event: MouseEvent) => {
const matchesMediumBreakpoint = matchMedia(MediaQueryBreakpoints.md).matches
if (!matchesMediumBreakpoint) {
return
}
const target = event.target as Element
const isDescendantOfMenu = popoverElement?.contains(target)
const isAnchorElement = anchorElement ? anchorElement === event.target || anchorElement.contains(target) : false
const closestPopoverId = target.closest('[data-popover]')?.getAttribute('data-popover')
const isDescendantOfChildPopover = closestPopoverId && childPopovers.has(closestPopoverId)
const isDescendantOfModal = !!target.closest('[aria-modal="true"]')
const isPopoverInModal = popoverElement?.closest('[aria-modal="true"]')
const isDescendantOfModal = isPopoverInModal ? false : !!target.closest('[aria-modal="true"]')
if (!isDescendantOfMenu && !isAnchorElement && !isDescendantOfChildPopover && !isDescendantOfModal) {
if (!disabled) {

View File

@ -3,11 +3,12 @@ import { createPortal } from 'react-dom'
type Props = {
children: ReactNode
disabled?: boolean
}
const randomPortalId = () => Math.random()
const Portal = ({ children }: Props) => {
const Portal = ({ children, disabled = false }: Props) => {
const [container, setContainer] = useState<HTMLElement>()
useEffect(() => {
@ -18,6 +19,10 @@ const Portal = ({ children }: Props) => {
return () => container.remove()
}, [])
if (disabled) {
return <>{children}</>
}
return container ? createPortal(children, container) : null
}

View File

@ -1,13 +1,9 @@
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Button from '@/Components/Button/Button'
import { FunctionComponent, useState } from 'react'
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import { WebApplication } from '@/Application/Application'
import { useBeforeUnload } from '@/Hooks/useBeforeUnload'
import ChangeEmailForm from './ChangeEmailForm'
import ChangeEmailSuccess from './ChangeEmailSuccess'
import Modal, { ModalAction } from '@/Components/Shared/Modal'
enum SubmitButtonTitles {
Default = 'Continue',
@ -37,7 +33,7 @@ const ChangeEmail: FunctionComponent<Props> = ({ onCloseDialog, application }) =
const applicationAlertService = application.alertService
const validateCurrentPassword = async () => {
const validateCurrentPassword = useCallback(async () => {
if (!currentPassword || currentPassword.length === 0) {
applicationAlertService.alert('Please enter your current password.').catch(console.error)
@ -54,14 +50,14 @@ const ChangeEmail: FunctionComponent<Props> = ({ onCloseDialog, application }) =
}
return success
}
}, [application, applicationAlertService, currentPassword])
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default)
setIsContinuing(false)
}
const processEmailChange = async () => {
const processEmailChange = useCallback(async () => {
await application.downloadBackup()
setLockContinue(true)
@ -73,17 +69,17 @@ const ChangeEmail: FunctionComponent<Props> = ({ onCloseDialog, application }) =
setLockContinue(false)
return success
}
}, [application, currentPassword, newEmail])
const dismiss = () => {
const dismiss = useCallback(() => {
if (lockContinue) {
applicationAlertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
onCloseDialog()
}
}
}, [applicationAlertService, lockContinue, onCloseDialog])
const handleSubmit = async () => {
const handleSubmit = useCallback(async () => {
if (lockContinue || isContinuing) {
return
}
@ -115,31 +111,43 @@ const ChangeEmail: FunctionComponent<Props> = ({ onCloseDialog, application }) =
setIsContinuing(false)
setSubmitButtonTitle(SubmitButtonTitles.Finish)
setCurrentStep(Steps.FinishStep)
}
}, [currentStep, dismiss, isContinuing, lockContinue, processEmailChange, validateCurrentPassword])
const handleDialogClose = () => {
const handleDialogClose = useCallback(() => {
if (lockContinue) {
applicationAlertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
onCloseDialog()
}
}
}, [applicationAlertService, lockContinue, onCloseDialog])
const modalActions = useMemo(
(): ModalAction[] => [
{
label: 'Cancel',
onClick: handleDialogClose,
type: 'cancel',
mobileSlot: 'left',
},
{
label: submitButtonTitle,
onClick: handleSubmit,
type: 'primary',
mobileSlot: 'right',
},
],
[handleDialogClose, handleSubmit, submitButtonTitle],
)
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>Change Email</ModalDialogLabel>
<ModalDialogDescription className="flex flex-row items-center px-4.5">
{currentStep === Steps.InitialStep && (
<ChangeEmailForm setNewEmail={setNewEmail} setCurrentPassword={setCurrentPassword} />
)}
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
</ModalDialogDescription>
<ModalDialogButtons className="px-4.5">
<Button className="min-w-20" primary label={submitButtonTitle} onClick={handleSubmit} />
</ModalDialogButtons>
</ModalDialog>
</div>
<Modal title="Change Email" close={handleDialogClose} actions={modalActions}>
<div className="px-4.5 py-4">
{currentStep === Steps.InitialStep && (
<ChangeEmailForm setNewEmail={setNewEmail} setCurrentPassword={setCurrentPassword} />
)}
{currentStep === Steps.FinishStep && <ChangeEmailSuccess />}
</div>
</Modal>
)
}

View File

@ -10,6 +10,7 @@ import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import PasswordWizard from '@/Components/PasswordWizard/PasswordWizard'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import ModalOverlay from '@/Components/Shared/ModalOverlay'
type Props = {
application: WebApplication
@ -33,6 +34,8 @@ const Credentials: FunctionComponent<Props> = ({ application }: Props) => {
setShouldShowPasswordWizard(false)
}, [])
const closeChangeEmailDialog = () => setIsChangeEmailDialogOpen(false)
return (
<>
<PreferencesGroup>
@ -55,14 +58,14 @@ const Credentials: FunctionComponent<Props> = ({ application }: Props) => {
Current password was set on <span className="font-bold">{passwordCreatedOn}</span>
</Text>
<Button className="mt-3 min-w-20" label="Change password" onClick={presentPasswordWizard} />
{isChangeEmailDialogOpen && (
<ChangeEmail onCloseDialog={() => setIsChangeEmailDialogOpen(false)} application={application} />
)}
<ModalOverlay isOpen={isChangeEmailDialogOpen} onDismiss={closeChangeEmailDialog}>
<ChangeEmail onCloseDialog={closeChangeEmailDialog} application={application} />
</ModalOverlay>
</PreferencesSegment>
</PreferencesGroup>
{shouldShowPasswordWizard ? (
<ModalOverlay isOpen={shouldShowPasswordWizard} onDismiss={dismissPasswordWizard}>
<PasswordWizard application={application} dismissModal={dismissPasswordWizard} />
) : null}
</ModalOverlay>
</>
)
}

View File

@ -1,19 +1,15 @@
import { FunctionComponent, useState } from 'react'
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { isEmailValid } from '@/Utils'
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
import InviteForm from './InviteForm'
import InviteSuccess from './InviteSuccess'
import Modal, { ModalAction } from '@/Components/Shared/Modal'
enum SubmitButtonTitles {
Default = 'Send Invite',
Default = 'Invite',
Sending = 'Sending...',
Finish = 'Finish',
}
@ -36,7 +32,7 @@ const Invite: FunctionComponent<Props> = ({ onCloseDialog, application, subscrip
const [lockContinue, setLockContinue] = useState(false)
const [currentStep, setCurrentStep] = useState(Steps.InitialStep)
const validateInviteeEmail = async () => {
const validateInviteeEmail = useCallback(async () => {
if (!isEmailValid(inviteeEmail)) {
application.alertService
.alert('The email you entered has an invalid format. Please review your input and try again.')
@ -46,22 +42,22 @@ const Invite: FunctionComponent<Props> = ({ onCloseDialog, application, subscrip
}
return true
}
}, [application.alertService, inviteeEmail])
const handleDialogClose = () => {
const handleDialogClose = useCallback(() => {
if (lockContinue) {
application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
onCloseDialog()
}
}
}, [application.alertService, lockContinue, onCloseDialog])
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default)
setIsContinuing(false)
}
const processInvite = async () => {
const processInvite = useCallback(async () => {
setLockContinue(true)
const success = await subscriptionState.sendSubscriptionInvitation(inviteeEmail)
@ -69,9 +65,9 @@ const Invite: FunctionComponent<Props> = ({ onCloseDialog, application, subscrip
setLockContinue(false)
return success
}
}, [inviteeEmail, subscriptionState])
const handleSubmit = async () => {
const handleSubmit = useCallback(async () => {
if (lockContinue || isContinuing) {
return
}
@ -107,21 +103,43 @@ const Invite: FunctionComponent<Props> = ({ onCloseDialog, application, subscrip
setIsContinuing(false)
setSubmitButtonTitle(SubmitButtonTitles.Finish)
setCurrentStep(Steps.FinishStep)
}
}, [
application.alertService,
currentStep,
handleDialogClose,
isContinuing,
lockContinue,
processInvite,
validateInviteeEmail,
])
const modalActions = useMemo(
(): ModalAction[] => [
{
label: submitButtonTitle,
onClick: handleSubmit,
type: 'primary',
mobileSlot: 'right',
disabled: lockContinue,
},
{
label: 'Cancel',
onClick: handleDialogClose,
type: 'cancel',
mobileSlot: 'left',
hidden: currentStep === Steps.FinishStep,
},
],
[currentStep, handleDialogClose, handleSubmit, lockContinue, submitButtonTitle],
)
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>Share your Subscription</ModalDialogLabel>
<ModalDialogDescription className="flex flex-row items-center px-4.5">
{currentStep === Steps.InitialStep && <InviteForm setInviteeEmail={setInviteeEmail} />}
{currentStep === Steps.FinishStep && <InviteSuccess />}
</ModalDialogDescription>
<ModalDialogButtons className="px-4.5">
<Button className="min-w-20" primary label={submitButtonTitle} onClick={handleSubmit} />
</ModalDialogButtons>
</ModalDialog>
</div>
<Modal title="Share your Subscription" close={handleDialogClose} actions={modalActions}>
<div className="px-4.5 py-4">
{currentStep === Steps.InitialStep && <InviteForm setInviteeEmail={setInviteeEmail} />}
{currentStep === Steps.FinishStep && <InviteSuccess />}
</div>
</Modal>
)
}

View File

@ -14,6 +14,7 @@ import InvitationsList from './InvitationsList'
import Invite from './Invite/Invite'
import Button from '@/Components/Button/Button'
import SharingStatusText from './SharingStatusText'
import ModalOverlay from '@/Components/Shared/ModalOverlay'
type Props = {
application: WebApplication
@ -28,6 +29,8 @@ const SubscriptionSharing: FunctionComponent<Props> = ({ application, viewContro
const isSubscriptionSharingFeatureAvailable =
application.features.getFeatureStatus(FeatureIdentifier.SubscriptionSharing) === FeatureStatus.Entitled
const closeInviteDialog = () => setIsInviteDialogOpen(false)
return (
<PreferencesGroup>
<PreferencesSegment>
@ -42,13 +45,13 @@ const SubscriptionSharing: FunctionComponent<Props> = ({ application, viewContro
{!subscriptionState.allInvitationsUsed && (
<Button className="min-w-20" label="Invite" onClick={() => setIsInviteDialogOpen(true)} />
)}
{isInviteDialogOpen && (
<ModalOverlay isOpen={isInviteDialogOpen} onDismiss={closeInviteDialog}>
<Invite
onCloseDialog={() => setIsInviteDialogOpen(false)}
onCloseDialog={closeInviteDialog}
application={application}
subscriptionState={subscriptionState}
/>
)}
</ModalOverlay>
</div>
) : (
<NoProSubscription application={application} />

View File

@ -1,15 +1,11 @@
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import IconPicker from '@/Components/Icon/IconPicker'
import Popover from '@/Components/Popover/Popover'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Modal, { ModalAction } from '@/Components/Shared/Modal'
import Spinner from '@/Components/Spinner/Spinner'
import { Platform, SmartViewDefaultIconName, VectorIconNameOrEmoji } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { EditSmartViewModalController } from './EditSmartViewModalController'
type Props = {
@ -63,15 +59,40 @@ const EditSmartViewModal = ({ controller, platform }: Props) => {
}
}, [isPredicateJsonValid])
const modalActions = useMemo(
(): ModalAction[] => [
{
label: 'Delete',
onClick: deleteView,
disabled: isSaving,
type: 'destructive',
},
{
label: 'Cancel',
onClick: closeDialog,
disabled: isSaving,
type: 'cancel',
mobileSlot: 'left',
},
{
label: isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save',
onClick: saveSmartView,
disabled: isSaving,
type: 'primary',
mobileSlot: 'right',
},
],
[closeDialog, deleteView, isSaving, saveSmartView],
)
if (!view) {
return null
}
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeDialog}>Edit Smart View "{view.title}"</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-col gap-4">
<Modal title={`Edit Smart View "${view.title}"`} close={closeDialog} actions={modalActions}>
<div className="px-4 py-4">
<div className="flex h-full flex-col gap-4">
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Title:</div>
<input
@ -115,9 +136,9 @@ const EditSmartViewModal = ({ controller, platform }: Props) => {
</div>
</Popover>
</div>
<div className="flex flex-col gap-2.5">
<div className="flex flex-grow flex-col gap-2.5">
<div className="text-sm font-semibold">Predicate:</div>
<div className="flex flex-col overflow-hidden rounded-md border border-border">
<div className="flex flex-grow flex-col overflow-hidden rounded-md border border-border">
<textarea
className="h-full min-h-[10rem] w-full flex-grow resize-none bg-default py-1.5 px-2.5 font-mono text-sm"
value={predicateJson}
@ -136,19 +157,8 @@ const EditSmartViewModal = ({ controller, platform }: Props) => {
</div>
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="mr-auto" disabled={isSaving} onClick={deleteView} colorStyle="danger">
Delete
</Button>
<Button disabled={isSaving} onClick={saveSmartView} primary colorStyle="info">
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
</Button>
<Button disabled={isSaving} onClick={closeDialog}>
Cancel
</Button>
</ModalDialogButtons>
</ModalDialog>
</div>
</Modal>
)
}

View File

@ -14,8 +14,8 @@ const SmartViewItem = ({ view, onEdit, onDelete }: Props) => {
return (
<div className="flex items-center gap-2 py-1.5">
<Icon type={view.iconString} size="custom" className="h-5.5 w-5.5" />
<span className="mr-auto text-sm">{view.title}</span>
<Icon type={view.iconString} size="custom" className="h-5.5 w-5.5 flex-shrink-0" />
<span className="mr-auto overflow-hidden text-ellipsis text-sm">{view.title}</span>
<Button small onClick={onEdit}>
Edit
</Button>

View File

@ -15,6 +15,7 @@ import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscripti
import { EditSmartViewModalController } from './EditSmartViewModalController'
import { STRING_DELETE_TAG } from '@/Constants/Strings'
import { confirmDialog } from '@standardnotes/ui-services'
import ModalOverlay from '@/Components/Shared/ModalOverlay'
type NewType = {
application: WebApplication
@ -88,12 +89,15 @@ const SmartViews = ({ application, featuresController }: Props) => {
)}
</PreferencesSegment>
</PreferencesGroup>
{!!editSmartViewModalController.view && (
<ModalOverlay isOpen={!!editSmartViewModalController.view} onDismiss={editSmartViewModalController.closeDialog}>
<EditSmartViewModal controller={editSmartViewModalController} platform={application.platform} />
)}
{addSmartViewModalController.isAddingSmartView && (
</ModalOverlay>
<ModalOverlay
isOpen={addSmartViewModalController.isAddingSmartView}
onDismiss={addSmartViewModalController.closeModal}
>
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
)}
</ModalOverlay>
</>
)
}

View File

@ -1,4 +1,3 @@
import Button from '@/Components/Button/Button'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import IconButton from '@/Components/Button/IconButton'
import { observer } from 'mobx-react-lite'
@ -7,10 +6,6 @@ import CopyButton from './CopyButton'
import Bullet from './Bullet'
import { downloadSecretKey } from './download-secret-key'
import { TwoFactorActivation } from './TwoFactorActivation'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Icon from '@/Components/Icon/Icon'
type Props = {
@ -18,72 +13,59 @@ type Props = {
}
const SaveSecretKey: FunctionComponent<Props> = ({ activation: act }) => {
const download = (
<IconButton
focusable={false}
title="Download"
icon="download"
className="p-0"
onClick={() => {
downloadSecretKey(act.secretKey)
}}
/>
)
return (
<ModalDialog>
<ModalDialogLabel
closeDialog={() => {
act.cancelActivation()
}}
>
Step 2 of 3 - Save secret key
</ModalDialogLabel>
<ModalDialogDescription className="h-33 flex flex-row items-center">
<div className="flex flex-grow flex-col">
<div className="flex flex-row flex-wrap items-center gap-1">
<Bullet />
<div className="text-sm">
<b>Save your secret key</b>{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
somewhere safe
</a>
:
</div>
<DecoratedInput
disabled={true}
right={[<CopyButton copyValue={act.secretKey} />, download]}
value={act.secretKey}
className={{ container: 'ml-2' }}
/>
<div className="h-33 flex flex-row items-center px-4 py-4">
<div className="flex flex-grow flex-col">
<div className="flex flex-row flex-wrap items-center gap-1">
<Bullet />
<div className="text-sm">
<b>Save your secret key</b>{' '}
<a
target="_blank"
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
>
somewhere safe
</a>
:
</div>
<div className="h-2" />
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
You can use this key to generate codes if you lose access to your authenticator app.
<br />
<a
target="_blank"
rel="noreferrer noopener"
className="underline hover:no-underline"
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key"
>
Learn more
<Icon className="ml-1 inline" type="open-in" size="small" />
</a>
</div>
<DecoratedInput
disabled={true}
right={[
<CopyButton copyValue={act.secretKey} />,
<IconButton
focusable={false}
title="Download"
icon="download"
className="p-0"
onClick={() => {
downloadSecretKey(act.secretKey)
}}
/>,
]}
value={act.secretKey}
className={{ container: 'ml-2' }}
/>
</div>
<div className="h-2" />
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
You can use this key to generate codes if you lose access to your authenticator app.
<br />
<a
target="_blank"
rel="noreferrer noopener"
className="underline hover:no-underline"
href="https://standardnotes.com/help/22/what-happens-if-i-lose-my-2fa-device-and-my-secret-key"
>
Learn more
<Icon className="ml-1 inline" type="open-in" size="small" />
</a>
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="min-w-20" label="Back" onClick={() => act.openScanQRCode()} />
<Button className="min-w-20" primary label="Next" onClick={() => act.openVerification()} />
</ModalDialogButtons>
</ModalDialog>
</div>
</div>
)
}

View File

@ -2,58 +2,53 @@ import { FunctionComponent } from 'react'
import { observer } from 'mobx-react-lite'
import QRCode from 'qrcode.react'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { TwoFactorActivation } from './TwoFactorActivation'
import AuthAppInfoTooltip from './AuthAppInfoPopup'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import CopyButton from './CopyButton'
import Bullet from './Bullet'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
type Props = {
activation: TwoFactorActivation
}
const ScanQRCode: FunctionComponent<Props> = ({ activation: act }) => {
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
return (
<ModalDialog>
<ModalDialogLabel closeDialog={act.cancelActivation}>Step 1 of 3 - Scan QR code</ModalDialogLabel>
<ModalDialogDescription className="h-33 flex flex-col items-center gap-5 md:flex-row">
<div className="w-25 h-25 flex items-center justify-center bg-info">
<QRCode className="border-2 border-solid border-neutral-contrast" value={act.qrCode} size={100} />
</div>
<div className="flex flex-grow flex-col gap-2">
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Open your <b>authenticator app</b>.
</div>
<div className="min-w-2" />
<AuthAppInfoTooltip />
<div className="h-33 flex flex-col items-center gap-5 px-4 py-4 md:flex-row">
<div className="flex items-center justify-center bg-info">
<QRCode
className="border-2 border-solid border-neutral-contrast"
value={act.qrCode}
size={isMobileScreen ? 200 : 150}
/>
</div>
<div className="flex flex-grow flex-col gap-2">
<div className="flex flex-row items-center">
<Bullet />
<div className="min-w-1" />
<div className="text-sm">
Open your <b>authenticator app</b>.
</div>
<div className="flex flex-row items-center">
<Bullet className="mt-2 self-start" />
<div className="min-w-1" />
<div className="flex-grow text-sm">
<b>Scan this QR code</b> or <b>add this secret key</b>:
</div>
</div>
<DecoratedInput
className={{ container: 'w-92 ml-4' }}
disabled={true}
value={act.secretKey}
right={[<CopyButton copyValue={act.secretKey} />]}
/>
<div className="min-w-2" />
<AuthAppInfoTooltip />
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="min-w-20" label="Cancel" onClick={() => act.cancelActivation()} />
<Button className="min-w-20" primary label="Next" onClick={() => act.openSaveSecretKey()} />
</ModalDialogButtons>
</ModalDialog>
<div className="flex flex-row items-center">
<Bullet className="mt-2 self-start" />
<div className="min-w-1" />
<div className="flex-grow text-sm">
<b>Scan this QR code</b> or <b>add this secret key</b>:
</div>
</div>
<DecoratedInput
className={{ container: 'w-92 ml-4' }}
disabled={true}
value={act.secretKey}
right={[<CopyButton copyValue={act.secretKey} />]}
/>
</div>
</div>
)
}

View File

@ -10,6 +10,8 @@ import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/Pre
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
import { WebApplication } from '@/Application/Application'
import RecoveryCodeBanner from '@/Components/RecoveryCodeBanner/RecoveryCodeBanner'
import Modal, { ModalAction } from '@/Components/Shared/Modal'
import ModalOverlay from '@/Components/Shared/ModalOverlay'
type Props = {
auth: TwoFactorAuth
@ -17,6 +19,74 @@ type Props = {
}
const TwoFactorAuthView: FunctionComponent<Props> = ({ auth, application }) => {
const shouldShowActivationModal = auth.status !== 'fetching' && is2FAActivation(auth.status)
const activationModalTitle = shouldShowActivationModal
? auth.status.activationStep === 'scan-qr-code'
? 'Step 1 of 3 - Scan QR code'
: auth.status.activationStep === 'save-secret-key'
? 'Step 2 of 3 - Save secret key'
: auth.status.activationStep === 'verification'
? 'Step 3 of 3 - Verification'
: auth.status.activationStep === 'success'
? 'Successfully Enabled'
: ''
: ''
const closeActivationModal = () => {
if (auth.status === 'fetching') {
return
}
if (!is2FAActivation(auth.status)) {
return
}
if (auth.status.activationStep === 'success') {
auth.status.finishActivation()
}
auth.status.cancelActivation()
}
const activationModalActions: ModalAction[] = shouldShowActivationModal
? [
{
label: 'Cancel',
onClick: auth.status.cancelActivation,
type: 'cancel',
mobileSlot: 'left',
hidden: auth.status.activationStep !== 'scan-qr-code',
},
{
label: 'Back',
onClick:
auth.status.activationStep === 'save-secret-key'
? auth.status.openScanQRCode
: auth.status.openSaveSecretKey,
type: 'cancel',
mobileSlot: 'left',
hidden: auth.status.activationStep !== 'save-secret-key' && auth.status.activationStep !== 'verification',
},
{
label: 'Next',
onClick:
auth.status.activationStep === 'scan-qr-code'
? auth.status.openSaveSecretKey
: auth.status.activationStep === 'save-secret-key'
? auth.status.openVerification
: auth.status.enable2FA,
type: 'primary',
mobileSlot: 'right',
hidden: auth.status.activationStep === 'success',
},
{
label: 'Finish',
onClick: auth.status.finishActivation,
type: 'primary',
mobileSlot: 'right',
hidden: auth.status.activationStep !== 'success',
},
]
: []
return (
<>
<PreferencesGroup>
@ -45,9 +115,11 @@ const TwoFactorAuthView: FunctionComponent<Props> = ({ auth, application }) => {
</PreferencesSegment>
)}
</PreferencesGroup>
{auth.status !== 'fetching' && is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} />
)}
<ModalOverlay isOpen={shouldShowActivationModal} onDismiss={closeActivationModal}>
<Modal title={activationModalTitle} close={closeActivationModal} actions={activationModalActions}>
{shouldShowActivationModal && <TwoFactorActivationView activation={auth.status} />}
</Modal>
</ModalOverlay>
</>
)
}

View File

@ -1,8 +1,3 @@
import Button from '@/Components/Button/Button'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import { Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
@ -12,18 +7,12 @@ type Props = {
activation: TwoFactorActivation
}
const TwoFactorSuccess: FunctionComponent<Props> = ({ activation: act }) => (
<ModalDialog>
<ModalDialogLabel closeDialog={act.finishActivation}>Successfully Enabled</ModalDialogLabel>
<ModalDialogDescription className="flex flex-row items-center">
<div className="flex flex-row items-center justify-center pt-2">
<Subtitle>Two-factor authentication has been successfully enabled for your account.</Subtitle>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="min-w-20" primary label="Finish" onClick={act.finishActivation} />
</ModalDialogButtons>
</ModalDialog>
const TwoFactorSuccess: FunctionComponent<Props> = () => (
<div className="flex flex-row items-center px-4 py-4">
<div className="flex flex-row items-center justify-center pt-2">
<Subtitle>Two-factor authentication has been successfully enabled for your account.</Subtitle>
</div>
</div>
)
export default observer(TwoFactorSuccess)

View File

@ -1,13 +1,8 @@
import Button from '@/Components/Button/Button'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import Bullet from './Bullet'
import { TwoFactorActivation } from './TwoFactorActivation'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
type Props = {
activation: TwoFactorActivation
@ -17,47 +12,40 @@ const Verification: FunctionComponent<Props> = ({ activation: act }) => {
const secretKeyClass = act.verificationStatus === 'invalid-secret' ? 'border-danger' : ''
const authTokenClass = act.verificationStatus === 'invalid-auth-code' ? 'border-danger' : ''
return (
<ModalDialog>
<ModalDialogLabel closeDialog={act.cancelActivation}>Step 3 of 3 - Verification</ModalDialogLabel>
<ModalDialogDescription className="h-33 flex flex-row items-center">
<div className="flex flex-grow flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-1">
<div className="text-sm">
<Bullet className="align-middle" />
<span className="align-middle">
Enter your <b>secret key</b>:
</span>
</div>
<DecoratedInput
className={{ container: `ml-2 w-full md:w-96 ${secretKeyClass}` }}
onChange={act.setInputSecretKey}
/>
</div>
<div className="flex flex-row flex-wrap items-center gap-1">
<div className="text-sm">
<Bullet className="align-middle" />
<span className="align-middle">
Verify the <b>authentication code</b> generated by your authenticator app:
</span>
</div>
<DecoratedInput
className={{ container: `ml-2 w-full md:w-30 ${authTokenClass}` }}
onChange={act.setInputOtpToken}
/>
<div className="h-33 flex flex-row items-center px-4 py-4">
<div className="flex flex-grow flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-1">
<div className="text-sm">
<Bullet className="align-middle" />
<span className="align-middle">
Enter your <b>secret key</b>:
</span>
</div>
<DecoratedInput
className={{ container: `ml-2 w-full md:w-96 ${secretKeyClass}` }}
onChange={act.setInputSecretKey}
/>
</div>
<div className="flex flex-row flex-wrap items-center gap-1">
<div className="text-sm">
<Bullet className="align-middle" />
<span className="align-middle">
Verify the <b>authentication code</b> generated by your authenticator app:
</span>
</div>
<DecoratedInput
className={{ container: `ml-2 w-full md:w-30 ${authTokenClass}` }}
onChange={act.setInputOtpToken}
/>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
{act.verificationStatus === 'invalid-auth-code' && (
<div className="flex-grow text-sm text-danger">Incorrect authentication code, please try again.</div>
)}
{act.verificationStatus === 'invalid-secret' && (
<div className="flex-grow text-sm text-danger">Incorrect secret key, please try again.</div>
)}
<Button className="min-w-20" label="Back" onClick={act.openSaveSecretKey} />
<Button className="min-w-20" primary label="Next" onClick={act.enable2FA} />
</ModalDialogButtons>
</ModalDialog>
</div>
</div>
)
}

View File

@ -7,6 +7,8 @@ import PreferencesMenuItem from './PreferencesComponents/MenuItem'
import { PreferencesMenu } from './PreferencesMenu'
import { PreferenceId } from '@standardnotes/ui-services'
import { useApplication } from '../ApplicationProvider'
import { classNames } from '@standardnotes/snjs'
import { isIOS } from '@/Utils'
type Props = {
menu: PreferencesMenu
@ -53,7 +55,12 @@ const PreferencesMenuView: FunctionComponent<Props> = ({ menu }) => {
}, [application])
return (
<div className="border-t border-border bg-default px-5 pt-2 md:border-0 md:bg-contrast md:px-0 md:py-0">
<div
className={classNames(
'border-t border-border bg-default px-5 pt-2 md:border-0 md:bg-contrast md:px-0 md:py-0',
isIOS() ? 'pb-safe-bottom' : 'pb-2 md:pb-0',
)}
>
<div className="hidden min-w-55 flex-col overflow-y-auto px-3 py-6 md:flex">
{menuItems.map((pref) => (
<PreferencesMenuItem
@ -81,6 +88,11 @@ const PreferencesMenuView: FunctionComponent<Props> = ({ menu }) => {
onChange={(paneId) => {
selectPane(paneId as PreferenceId)
}}
classNameOverride={{
wrapper: 'relative',
popover: 'bottom-full w-full max-h-max',
}}
portal={false}
/>
</div>
</div>

View File

@ -4,12 +4,13 @@ import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import PreferencesCanvas from './PreferencesCanvas'
import { PreferencesProps } from './PreferencesProps'
import { isIOS } from '@/Utils'
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { classNames } from '@standardnotes/utils'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { ESCAPE_COMMAND } from '@standardnotes/ui-services'
import Modal from '../Shared/Modal'
import { AlertDialogLabel } from '@reach/alert-dialog'
import { classNames } from '@standardnotes/snjs'
import { isIOS } from '@/Utils'
const PreferencesView: FunctionComponent<PreferencesProps> = ({
application,
@ -18,8 +19,6 @@ const PreferencesView: FunctionComponent<PreferencesProps> = ({
userProvider,
mfaProvider,
}) => {
const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md)
const menu = useMemo(
() => new PreferencesMenu(application, viewControllerManager.enableUnfinishedFeatures),
[viewControllerManager.enableUnfinishedFeatures, application],
@ -56,26 +55,32 @@ const PreferencesView: FunctionComponent<PreferencesProps> = ({
}, [addAndroidBackHandler, closePreferences])
return (
<div
className={classNames(
'absolute top-0 left-0 z-preferences flex h-full w-full flex-col bg-default pt-safe-top',
isIOS() ? 'pb-safe-bottom' : 'pb-2 md:pb-0',
)}
style={{
top: !isDesktopScreen ? `${document.documentElement.scrollTop}px` : '',
<Modal
close={closePreferences}
title="Preferences"
className={{
content: 'md:h-full md:!max-h-full md:!w-full',
description: 'flex flex-col',
}}
customHeader={
<AlertDialogLabel
className={classNames(
'flex w-full flex-row items-center justify-between border-b border-solid border-border bg-default px-3 pb-2 md:p-3',
isIOS() ? 'pt-safe-top' : 'pt-2',
)}
>
<div className="hidden h-8 w-8 md:block" />
<h1 className="text-base font-bold md:text-lg">Your preferences for Standard Notes</h1>
<RoundIconButton
onClick={() => {
closePreferences()
}}
icon="close"
label="Close preferences"
/>
</AlertDialogLabel>
}
>
<div className="flex w-full flex-row items-center justify-between border-b border-solid border-border bg-default px-3 py-2 md:p-3">
<div className="hidden h-8 w-8 md:block" />
<h1 className="text-base font-bold md:text-lg">Your preferences for Standard Notes</h1>
<RoundIconButton
onClick={() => {
closePreferences()
}}
icon="close"
label="Close preferences"
/>
</div>
<PreferencesCanvas
menu={menu}
application={application}
@ -84,7 +89,7 @@ const PreferencesView: FunctionComponent<PreferencesProps> = ({
userProvider={userProvider}
mfaProvider={mfaProvider}
/>
</div>
</Modal>
)
}

View File

@ -4,6 +4,7 @@ import PreferencesView from './PreferencesView'
import { PreferencesViewWrapperProps } from './PreferencesViewWrapperProps'
import { useCommandService } from '../CommandProvider'
import { OPEN_PREFERENCES_COMMAND } from '@standardnotes/ui-services'
import ModalOverlay from '../Shared/ModalOverlay'
const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperProps> = ({
viewControllerManager,
@ -18,18 +19,16 @@ const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperProps> = (
})
}, [commandService, viewControllerManager])
if (!viewControllerManager.preferencesController?.isOpen) {
return null
}
return (
<PreferencesView
closePreferences={() => viewControllerManager.preferencesController.closePreferences()}
application={application}
viewControllerManager={viewControllerManager}
mfaProvider={application}
userProvider={application}
/>
<ModalOverlay isOpen={viewControllerManager.preferencesController?.isOpen} className="p-0">
<PreferencesView
closePreferences={() => viewControllerManager.preferencesController.closePreferences()}
application={application}
viewControllerManager={viewControllerManager}
mfaProvider={application}
userProvider={application}
/>
</ModalOverlay>
)
}

View File

@ -1,19 +1,20 @@
import { getPlatformString } from '@/Utils'
import { classNames } from '@standardnotes/utils'
import { DialogOverlay, DialogContent } from '@reach/dialog'
import { ReactNode } from 'react'
import { ForwardedRef, forwardRef, ReactNode } from 'react'
type Props = {
children: ReactNode
onDismiss: () => void
}
const HistoryModalDialog = ({ children, onDismiss }: Props) => {
const HistoryModalDialog = forwardRef(({ children, onDismiss }: Props, ref: ForwardedRef<HTMLDivElement>) => {
return (
<DialogOverlay
className={`sn-component ${getPlatformString()}`}
onDismiss={onDismiss}
aria-label="Note revision history"
ref={ref}
>
<DialogContent
aria-label="Note revision history"
@ -26,6 +27,6 @@ const HistoryModalDialog = ({ children, onDismiss }: Props) => {
</DialogContent>
</DialogOverlay>
)
}
})
export default HistoryModalDialog

View File

@ -46,6 +46,7 @@ const HistoryModalDialogContent = ({
>
Content
</button>
<div className="mx-auto text-base font-semibold">History</div>
<button className="ml-auto mr-2 rounded-full border border-border p-1.5" onClick={dismissModal}>
<Icon type="close" className="h-4 w-4" />
</button>

View File

@ -4,6 +4,7 @@ import HistoryModalDialogContent from './HistoryModalDialogContent'
import HistoryModalDialog from './HistoryModalDialog'
import { RevisionHistoryModalProps } from './RevisionHistoryModalProps'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { useModalAnimation } from '../Shared/useModalAnimation'
const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
application,
@ -14,7 +15,9 @@ const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
}) => {
const addAndroidBackHandler = useAndroidBackHandler()
const isOpen = !!historyModalController.note
const isOpen = Boolean(
historyModalController.note && application.isAuthorizedToRenderItem(historyModalController.note),
)
useEffect(() => {
let removeListener: (() => void) | undefined
@ -33,24 +36,24 @@ const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
}
}, [addAndroidBackHandler, historyModalController, isOpen])
if (!historyModalController.note) {
return null
}
const [isMounted, setElement] = useModalAnimation(isOpen)
if (!application.isAuthorizedToRenderItem(historyModalController.note)) {
if (!isMounted) {
return null
}
return (
<HistoryModalDialog onDismiss={historyModalController.dismissModal}>
<HistoryModalDialogContent
application={application}
dismissModal={historyModalController.dismissModal}
note={historyModalController.note}
notesController={notesController}
selectionController={selectionController}
subscriptionController={subscriptionController}
/>
<HistoryModalDialog onDismiss={historyModalController.dismissModal} ref={setElement}>
{!!historyModalController.note && (
<HistoryModalDialogContent
application={application}
dismissModal={historyModalController.dismissModal}
note={historyModalController.note}
notesController={notesController}
selectionController={selectionController}
subscriptionController={subscriptionController}
/>
)}
</HistoryModalDialog>
)
}

View File

@ -1,16 +1,15 @@
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { SNApplication, SessionStrings, UuidString, isNullOrUndefined, RemoteSession } from '@standardnotes/snjs'
import { FunctionComponent, useState, useEffect, useRef, useMemo } from 'react'
import { FunctionComponent, useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { Alert } from '@reach/alert'
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import Spinner from '@/Components/Spinner/Spinner'
import Button from '@/Components/Button/Button'
import Icon from '../Icon/Icon'
import Modal, { ModalAction } from '../Shared/Modal'
import ModalOverlay from '../Shared/ModalOverlay'
type Session = RemoteSession & {
revoking?: true
@ -82,7 +81,7 @@ const SessionsModalContent: FunctionComponent<{
viewControllerManager: ViewControllerManager
application: SNApplication
}> = ({ viewControllerManager, application }) => {
const close = () => viewControllerManager.closeSessionsModal()
const close = useCallback(() => viewControllerManager.closeSessionsModal(), [viewControllerManager])
const [sessions, refresh, refreshing, revokeSession, errorMessage] = useSessions(application)
@ -106,20 +105,29 @@ const SessionsModalContent: FunctionComponent<{
const closeRevokeConfirmationDialog = () => {
setRevokingSessionUuid('')
}
const sessionModalActions = useMemo(
(): ModalAction[] => [
{
label: 'Close',
onClick: close,
type: 'cancel',
mobileSlot: 'left',
},
{
label: 'Refresh',
onClick: refresh,
type: 'primary',
mobileSlot: 'right',
},
],
[close, refresh],
)
return (
<>
<ModalDialog onDismiss={close} className="sessions-modal max-h-[90vh]">
<ModalDialogLabel
headerButtons={
<Button small colorStyle="info" onClick={refresh}>
Refresh
</Button>
}
closeDialog={close}
>
Active Sessions
</ModalDialogLabel>
<ModalDialogDescription className="overflow-y-auto">
<Modal title="Active Sessions" close={close} actions={sessionModalActions}>
<div className="px-4 py-4">
{refreshing ? (
<div className="flex items-center gap-2">
<Spinner className="h-3 w-3" />
@ -155,8 +163,8 @@ const SessionsModalContent: FunctionComponent<{
)}
</>
)}
</ModalDialogDescription>
</ModalDialog>
</div>
</Modal>
{confirmRevokingSessionUuid && (
<AlertDialog onDismiss={closeRevokeConfirmationDialog} leastDestructiveRef={cancelRevokeRef} className="p-0">
<div className="sk-modal-content">
@ -206,11 +214,15 @@ const SessionsModal: FunctionComponent<{
viewControllerManager: ViewControllerManager
application: WebApplication
}> = ({ viewControllerManager, application }) => {
if (viewControllerManager.isSessionsModalVisible) {
return <SessionsModalContent application={application} viewControllerManager={viewControllerManager} />
} else {
return null
}
return (
<ModalOverlay
isOpen={viewControllerManager.isSessionsModalVisible}
onDismiss={viewControllerManager.closeSessionsModal}
className="sessions-modal"
>
<SessionsModalContent application={application} viewControllerManager={viewControllerManager} />
</ModalOverlay>
)
}
export default observer(SessionsModal)

View File

@ -0,0 +1,31 @@
import { classNames } from '@standardnotes/snjs'
import { ComponentPropsWithoutRef, ForwardedRef, forwardRef, ReactNode } from 'react'
type Props = {
children: ReactNode
action: () => void
slot: 'left' | 'right'
type?: 'primary' | 'secondary' | 'destructive' | 'cancel'
} & Omit<ComponentPropsWithoutRef<'button'>, 'onClick' | 'type'>
const MobileModalAction = forwardRef(
({ children, action, type = 'primary', slot, className, ...props }: Props, ref: ForwardedRef<HTMLButtonElement>) => {
return (
<button
ref={ref}
className={classNames(
'flex whitespace-nowrap py-1 px-1 text-base font-semibold focus:shadow-none focus:outline-none active:shadow-none active:outline-none disabled:text-neutral md:hidden',
slot === 'left' ? 'justify-start text-left' : 'justify-end text-right',
type === 'cancel' || type === 'destructive' ? 'text-danger' : 'text-info',
className,
)}
onClick={action}
{...props}
>
{children}
</button>
)
},
)
export default MobileModalAction

View File

@ -0,0 +1,215 @@
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { isIOS } from '@/Utils'
import { AlertDialogContent, AlertDialogLabel } from '@reach/alert-dialog'
import { classNames } from '@standardnotes/snjs'
import { ReactNode, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import MobileModalAction from './MobileModalAction'
import ModalAndroidBackHandler from './ModalAndroidBackHandler'
import ModalDialogDescription from './ModalDialogDescription'
export type ModalAction = {
label: NonNullable<ReactNode>
type: 'primary' | 'secondary' | 'destructive' | 'cancel'
onClick: () => void
mobileSlot?: 'left' | 'right'
hidden?: boolean
disabled?: boolean
}
type Props = {
title: string
close: () => void
actions?: ModalAction[]
className?: {
content?: string
description?: string
}
customHeader?: ReactNode
customFooter?: ReactNode
children: ReactNode
}
const Modal = ({ title, close, actions = [], className = {}, customHeader, customFooter, children }: Props) => {
const sortedActions = useMemo(
() =>
actions
.sort((a, b) => {
if (a.type === 'cancel') {
return -1
}
if (b.type === 'cancel') {
return 1
}
if (a.type === 'destructive') {
return -1
}
if (b.type === 'destructive') {
return 1
}
if (a.type === 'secondary') {
return -1
}
if (b.type === 'secondary') {
return 1
}
return 0
})
.filter((action) => !action.hidden),
[actions],
)
const primaryActions = sortedActions.filter((action) => action.type === 'primary')
if (primaryActions.length > 1) {
throw new Error('Modal can only have 1 primary action')
}
const cancelActions = sortedActions.filter((action) => action.type === 'cancel')
if (cancelActions.length > 1) {
throw new Error('Modal can only have 1 cancel action')
}
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const leftSlotAction = sortedActions.find((action) => action.mobileSlot === 'left')
const rightSlotAction = sortedActions.find((action) => action.mobileSlot === 'right')
const hasCancelAction = sortedActions.some((action) => action.type === 'cancel')
const firstPrimaryActionIndex = sortedActions.findIndex((action) => action.type === 'primary')
const extraActions = sortedActions.filter((action) => action.type !== 'primary' && action.type !== 'cancel')
const [showAdvanced, setShowAdvanced] = useState(false)
const advancedOptionRef = useRef<HTMLButtonElement>(null)
return (
<>
<ModalAndroidBackHandler close={close} />
<AlertDialogContent
tabIndex={0}
className={classNames(
'm-0 flex h-full w-full flex-col border-solid border-border bg-default p-0 md:h-auto md:max-h-[85vh] md:w-160 md:rounded md:border md:shadow-main',
className.content,
)}
>
{customHeader ? (
customHeader
) : (
<AlertDialogLabel
className={classNames(
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default text-text md:px-4.5 md:py-3',
isIOS() ? 'pt-safe-top' : 'py-1.5 px-2',
)}
>
<div className="grid w-full grid-cols-[0.35fr_1fr_0.35fr] flex-row items-center justify-between gap-2 md:flex md:gap-0">
{leftSlotAction ? (
<MobileModalAction
type={leftSlotAction.type}
action={leftSlotAction.onClick}
disabled={leftSlotAction.disabled}
slot="left"
>
{leftSlotAction.label}
</MobileModalAction>
) : (
<div className="md:hidden" />
)}
<div className="flex items-center justify-center gap-2 overflow-hidden text-center text-base font-semibold text-text md:flex-grow md:text-left md:text-lg">
{extraActions.length > 0 && (
<>
<MobileModalAction
type="secondary"
action={() => setShowAdvanced((show) => !show)}
slot="left"
ref={advancedOptionRef}
>
<div className="rounded-full border border-border p-0.5">
<Icon type="more" />
</div>
</MobileModalAction>
<Popover
title="Advanced"
open={showAdvanced}
anchorElement={advancedOptionRef.current}
disableMobileFullscreenTakeover={true}
togglePopover={() => setShowAdvanced((show) => !show)}
align="start"
portal={false}
className="w-1/2 !min-w-0 divide-y divide-border border border-border"
>
{extraActions
.filter((action) => action.type !== 'cancel')
.map((action, index) => (
<button
className={classNames(
'p-2 text-base font-semibold hover:bg-contrast focus:bg-info-backdrop focus:shadow-none focus:outline-none',
action.type === 'destructive' && 'text-danger',
)}
key={index}
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</button>
))}
</Popover>
</>
)}
<span className="overflow-hidden text-ellipsis whitespace-nowrap ">{title}</span>
</div>
<div className="hidden items-center gap-2 md:flex">
<button tabIndex={0} className="ml-2 rounded p-1 font-bold hover:bg-contrast" onClick={close}>
<Icon type="close" />
</button>
</div>
{rightSlotAction ? (
<MobileModalAction
type={rightSlotAction.type}
action={rightSlotAction.onClick}
disabled={rightSlotAction.disabled}
slot="right"
>
{rightSlotAction.label}
</MobileModalAction>
) : sortedActions.length === 0 || !hasCancelAction ? (
<MobileModalAction children="Done" action={close} slot="right" />
) : null}
</div>
</AlertDialogLabel>
)}
<ModalDialogDescription className={className.description}>{children}</ModalDialogDescription>
{customFooter
? customFooter
: sortedActions.length > 0 && (
<div
className={classNames(
'hidden items-center justify-start gap-3 border-t border-border py-2 px-2.5 md:flex md:px-4 md:py-4',
isIOS() && 'pb-safe-bottom',
)}
>
{sortedActions.map((action, index) => (
<Button
primary={action.type === 'primary'}
colorStyle={action.type === 'destructive' ? 'danger' : undefined}
key={action.label.toString()}
onClick={action.onClick}
className={classNames(
action.mobileSlot ? 'hidden md:block' : '',
index === firstPrimaryActionIndex && 'ml-auto',
)}
data-type={action.type}
disabled={action.disabled}
small={isMobileScreen}
>
{action.label}
</Button>
))}
</div>
)}
</AlertDialogContent>
</>
)
}
export default Modal

View File

@ -0,0 +1,28 @@
import { useStateRef } from '@/Hooks/useStateRef'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { useEffect } from 'react'
type Props = {
close: () => void
}
const ModalAndroidBackHandler = ({ close }: Props) => {
const addAndroidBackHandler = useAndroidBackHandler()
const closeFnRef = useStateRef(close)
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
closeFnRef.current()
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, closeFnRef])
return null
}
export default ModalAndroidBackHandler

View File

@ -12,11 +12,11 @@ const ModalDialog = ({ children, onDismiss, className }: Props) => {
const ldRef = useRef<HTMLButtonElement>(null)
return (
<AlertDialogOverlay className="px-4 md:px-0" leastDestructiveRef={ldRef} onDismiss={onDismiss}>
<AlertDialogOverlay className="p-0 md:px-0" leastDestructiveRef={ldRef} onDismiss={onDismiss}>
<AlertDialogContent
tabIndex={0}
className={classNames(
'flex max-h-[85vh] w-full flex-col rounded border border-solid border-border bg-default p-0 shadow-main md:w-160',
'm-0 flex w-full flex-col border-solid border-border bg-default p-0 shadow-main md:max-h-[85vh] md:w-160 md:rounded md:border',
className,
)}
>

View File

@ -1,3 +1,4 @@
import { isIOS } from '@/Utils'
import { classNames } from '@standardnotes/utils'
import { FunctionComponent, ReactNode } from 'react'
@ -9,7 +10,11 @@ type Props = {
const ModalDialogButtons: FunctionComponent<Props> = ({ children, className }) => (
<>
<hr className="m-0 h-[1px] border-none bg-border" />
<div className={classNames('flex items-center justify-end gap-3 px-4 py-4', className)}>{children}</div>
<div
className={classNames('flex items-center justify-end gap-3 px-4 py-4', isIOS() && 'pb-safe-bottom', className)}
>
{children}
</div>
</>
)

View File

@ -7,7 +7,7 @@ type Props = {
}
const ModalDialogDescription: FunctionComponent<Props> = ({ children, className = '' }) => (
<AlertDialogDescription className={`overflow-y-auto px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
<AlertDialogDescription className={`flex-grow overflow-y-auto ${className}`}>{children}</AlertDialogDescription>
)
export default ModalDialogDescription

View File

@ -3,15 +3,26 @@ import { AlertDialogLabel } from '@reach/alert-dialog'
import Icon from '@/Components/Icon/Icon'
import { classNames } from '@standardnotes/utils'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { isIOS } from '@/Utils'
import MobileModalAction from './MobileModalAction'
type Props = {
closeDialog: () => void
className?: string
headerButtons?: ReactNode
leftMobileButton?: ReactNode
rightMobileButton?: ReactNode
children?: ReactNode
}
const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, className, headerButtons }) => {
const ModalDialogLabel: FunctionComponent<Props> = ({
children,
closeDialog,
className,
headerButtons,
leftMobileButton,
rightMobileButton,
}) => {
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
@ -29,18 +40,31 @@ const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, cla
return (
<AlertDialogLabel
className={classNames(
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default px-4.5 py-3 text-text',
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default py-1.5 px-1 text-text md:px-4.5 md:py-3',
isIOS() && 'pt-safe-top',
className,
)}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex-grow text-lg font-semibold text-text">{children}</div>
<div className="flex items-center gap-2">
<div className="grid w-full grid-cols-[0.35fr_1fr_0.35fr] flex-row items-center justify-between gap-2 md:flex md:gap-0">
{leftMobileButton ? leftMobileButton : <div className="md:hidden" />}
<div
className={classNames(
'overflow-hidden text-ellipsis whitespace-nowrap text-center text-base font-semibold text-text md:flex-grow md:text-left md:text-lg',
)}
>
{children}
</div>
<div className="hidden items-center gap-2 md:flex">
{headerButtons}
<button tabIndex={0} className="rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}>
<button tabIndex={0} className="ml-2 rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}>
<Icon type="close" />
</button>
</div>
{rightMobileButton ? (
rightMobileButton
) : (
<MobileModalAction slot="right" children="Done" action={closeDialog} />
)}
</div>
<hr className="h-1px no-border m-0 bg-border" />
</AlertDialogLabel>

View File

@ -0,0 +1,34 @@
import { AlertDialogOverlay } from '@reach/alert-dialog'
import { classNames } from '@standardnotes/snjs'
import { ReactNode, useRef } from 'react'
import { useModalAnimation } from './useModalAnimation'
type Props = {
isOpen: boolean
onDismiss?: () => void
children: ReactNode
className?: string
}
const ModalOverlay = ({ isOpen, onDismiss, children, className }: Props) => {
const ldRef = useRef<HTMLButtonElement>(null)
const [isMounted, setElement] = useModalAnimation(isOpen)
if (!isMounted) {
return null
}
return (
<AlertDialogOverlay
className={classNames('p-0 md:px-0 md:opacity-100', className)}
leastDestructiveRef={ldRef}
onDismiss={onDismiss}
ref={setElement}
>
{children}
</AlertDialogOverlay>
)
}
export default ModalOverlay

View File

@ -0,0 +1,52 @@
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
export const useModalAnimation = (isOpen: boolean) => {
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
return useLifecycleAnimation(
{
open: isOpen,
enter: {
keyframes: [
{
transform: 'translateY(100%)',
},
{
transform: 'translateY(0)',
},
],
options: {
easing: 'cubic-bezier(.36,.66,.04,1)',
duration: 250,
fill: 'forwards',
},
initialStyle: {
transformOrigin: 'bottom',
},
},
enterCallback: (element) => {
element.scrollTop = 0
},
exit: {
keyframes: [
{
transform: 'translateY(0)',
},
{
transform: 'translateY(100%)',
},
],
options: {
easing: 'cubic-bezier(.36,.66,.04,1)',
duration: 250,
fill: 'forwards',
},
initialStyle: {
transformOrigin: 'bottom',
},
},
},
!isMobileScreen,
)
}

View File

@ -1,16 +1,11 @@
import Button from '@/Components/Button/Button'
import CompoundPredicateBuilder from '@/Components/SmartViewBuilder/CompoundPredicateBuilder'
import Icon from '@/Components/Icon/Icon'
import IconPicker from '@/Components/Icon/IconPicker'
import Popover from '@/Components/Popover/Popover'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Spinner from '@/Components/Spinner/Spinner'
import { Platform, SmartViewDefaultIconName, VectorIconNameOrEmoji } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AddSmartViewModalController } from './AddSmartViewModalController'
import TabPanel from '../Tabs/TabPanel'
import { useTabState } from '../Tabs/useTabState'
@ -18,6 +13,7 @@ import TabsContainer from '../Tabs/TabsContainer'
import CopyableCodeBlock from '../Shared/CopyableCodeBlock'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { classNames } from '@standardnotes/utils'
import Modal, { ModalAction } from '../Shared/Modal'
type Props = {
controller: AddSmartViewModalController
@ -91,7 +87,7 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
defaultTab: 'builder',
})
const save = () => {
const save = useCallback(() => {
if (!title.length) {
titleInputRef.current?.focus()
return
@ -103,7 +99,13 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
}
void saveCurrentSmartView()
}
}, [
isCustomJsonValidPredicate,
saveCurrentSmartView,
tabState.activeTab,
title.length,
validateAndPrettifyCustomPredicate,
])
const canSave = tabState.activeTab === 'builder' || isCustomJsonValidPredicate
@ -117,11 +119,30 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
}
}, [isCustomJsonValidPredicate, tabState.activeTab])
const modalActions = useMemo(
(): ModalAction[] => [
{
label: 'Cancel',
onClick: closeModal,
disabled: isSaving,
type: 'cancel',
mobileSlot: 'left',
},
{
label: isSaving ? <Spinner className="h-4.5 w-4.5" /> : canSave ? 'Save' : 'Validate',
onClick: save,
disabled: isSaving,
mobileSlot: 'right',
type: 'primary',
},
],
[canSave, closeModal, isSaving, save],
)
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeModal}>Add Smart View</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-col gap-4">
<Modal title="Add Smart View" close={closeModal} actions={modalActions}>
<div className="px-4 py-4">
<div className="flex h-full flex-col gap-4">
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Title:</div>
<input
@ -165,9 +186,10 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
</div>
</Popover>
</div>
<div className="flex flex-col gap-2.5">
<div className="flex flex-grow flex-col gap-2.5">
<div className="text-sm font-semibold">Predicate:</div>
<TabsContainer
className="flex flex-grow flex-col"
tabs={[
{
id: 'builder',
@ -183,7 +205,7 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
<TabPanel state={tabState} id="builder" className="flex flex-col gap-2.5 p-4">
<CompoundPredicateBuilder controller={predicateController} />
</TabPanel>
<TabPanel state={tabState} id="custom" className="flex flex-col">
<TabPanel state={tabState} id="custom" className="flex flex-grow flex-col">
<textarea
className="h-full min-h-[10rem] w-full flex-grow resize-none bg-default py-1.5 px-2.5 font-mono text-sm"
value={customPredicateJson}
@ -221,16 +243,8 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
)}
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button disabled={isSaving} onClick={closeModal} className="mr-auto">
Cancel
</Button>
<Button disabled={isSaving} onClick={save} colorStyle={canSave ? 'info' : 'default'} primary={canSave}>
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : canSave ? 'Save' : 'Validate'}
</Button>
</ModalDialogButtons>
</ModalDialog>
</div>
</Modal>
)
}

View File

@ -1,3 +1,4 @@
import { classNames } from '@standardnotes/snjs'
import Tab from './Tab'
import TabList from './TabList'
import { TabState } from './useTabState'
@ -9,11 +10,12 @@ type Props = {
}[]
state: TabState
children: React.ReactNode
className?: string
}
const TabsContainer = ({ tabs, state, children }: Props) => {
const TabsContainer = ({ tabs, state, className, children }: Props) => {
return (
<div className="overflow-hidden rounded-md border border-border">
<div className={classNames('overflow-hidden rounded-md border border-border', className)}>
<TabList state={state} className="border-b border-border">
{tabs.map(({ id, title }) => (
<Tab key={id} id={id} className="first:rounded-tl-md">

View File

@ -8,6 +8,7 @@ import { FunctionComponent, useCallback, useMemo } from 'react'
import IconButton from '../Button/IconButton'
import EditSmartViewModal from '../Preferences/Panes/General/SmartViews/EditSmartViewModal'
import { EditSmartViewModalController } from '../Preferences/Panes/General/SmartViews/EditSmartViewModalController'
import ModalOverlay from '../Shared/ModalOverlay'
import AddSmartViewModal from '../SmartViewBuilder/AddSmartViewModal'
import { AddSmartViewModalController } from '../SmartViewBuilder/AddSmartViewModalController'
import SmartViewsList from './SmartViewsList'
@ -53,12 +54,15 @@ const SmartViewsSection: FunctionComponent<Props> = ({ application, navigationCo
featuresController={featuresController}
setEditingSmartView={editSmartViewModalController.setView}
/>
{!!editSmartViewModalController.view && (
<ModalOverlay isOpen={!!editSmartViewModalController.view} onDismiss={editSmartViewModalController.closeDialog}>
<EditSmartViewModal controller={editSmartViewModalController} platform={application.platform} />
)}
{addSmartViewModalController.isAddingSmartView && (
</ModalOverlay>
<ModalOverlay
isOpen={addSmartViewModalController.isAddingSmartView}
onDismiss={addSmartViewModalController.closeModal}
>
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
)}
</ModalOverlay>
</section>
)
}

View File

@ -20,21 +20,18 @@ type Options = {
* @param exitCallback A callback to run after the exit animation finishes
* @returns A tuple containing whether the element can be mounted and a ref callback to set the element
*/
export const useLifecycleAnimation = ({
open,
enter,
enterCallback,
exit,
exitCallback,
}: Options): [boolean, RefCallback<HTMLElement | null>] => {
export const useLifecycleAnimation = (
{ open, enter, enterCallback, exit, exitCallback }: Options,
disabled = false,
): [boolean, RefCallback<HTMLElement | null>] => {
const [element, setElement] = useState<HTMLElement | null>(null)
const [isMounted, setIsMounted] = useState(() => open)
useEffect(() => {
if (open) {
if (disabled || open) {
setIsMounted(open)
}
}, [open])
}, [disabled, open])
// Using "state ref"s to prevent changes from re-running the effect below
// We only want changes to `open` and `element` to re-run the effect
@ -48,6 +45,11 @@ export const useLifecycleAnimation = ({
return
}
if (disabled) {
setIsMounted(open)
return
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (prefersReducedMotion) {
@ -88,7 +90,7 @@ export const useLifecycleAnimation = ({
})
.catch(console.error)
}
}, [open, element, enterRef, enterCallbackRef, exitRef, exitCallbackRef])
}, [open, element, enterRef, enterCallbackRef, exitRef, exitCallbackRef, disabled])
return [isMounted, setElement]
}