diff --git a/.eslintrc b/.eslintrc index 74fa349e3..3841621ad 100644 --- a/.eslintrc +++ b/.eslintrc @@ -19,7 +19,10 @@ "camelcase": "warn", "sort-imports": "off", "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks - "react-hooks/exhaustive-deps": "error" // Checks effect dependencies + "react-hooks/exhaustive-deps": "error", // Checks effect dependencies + "eol-last": "error", + "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], + "no-trailing-spaces": "error" }, "env": { "browser": true diff --git a/.gitignore b/.gitignore index 4656f67cc..01c40b0e4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # OS & IDE .DS_Store +.idea # Ignore bundler config. /.bundle diff --git a/Gemfile.lock b/Gemfile.lock index c578fae74..d2fe3cd95 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -213,7 +213,7 @@ DEPENDENCIES dotenv-rails haml lograge (~> 0.11.2) - newrelic_rpm + newrelic_rpm (~> 7.0) non-stupid-digest-assets puma rack-cors diff --git a/app/assets/icons/ic-copy.svg b/app/assets/icons/ic-copy.svg new file mode 100644 index 000000000..9ad40e8f1 --- /dev/null +++ b/app/assets/icons/ic-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-download.svg b/app/assets/icons/ic-download.svg new file mode 100644 index 000000000..de2c70fc2 --- /dev/null +++ b/app/assets/icons/ic-download.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-info.svg b/app/assets/icons/ic-info.svg new file mode 100644 index 000000000..47ea73219 --- /dev/null +++ b/app/assets/icons/ic-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index e7470bbe0..b493808ad 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -65,7 +65,7 @@ import { NotesContextMenuDirective } from './components/NotesContextMenu'; import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; import { IconDirective } from './components/Icon'; import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; -import { PreferencesDirective } from './components/preferences'; +import { PreferencesDirective } from './preferences'; function reloadHiddenFirefoxTab(): boolean { /** diff --git a/app/assets/javascripts/components/Button.tsx b/app/assets/javascripts/components/Button.tsx new file mode 100644 index 000000000..bf13b5509 --- /dev/null +++ b/app/assets/javascripts/components/Button.tsx @@ -0,0 +1,32 @@ +import { FunctionComponent } from 'preact'; + +const baseClass = `rounded px-4 py-1.75 font-bold text-sm fit-content`; + +const normalClass = `${baseClass} bg-default color-text border-solid border-gray-300 border-1 \ +focus:bg-contrast hover:bg-contrast`; +const primaryClass = `${baseClass} no-border bg-info color-info-contrast hover:brightness-130 \ +focus:brightness-130`; + +export const Button: FunctionComponent<{ + className?: string; + type: 'normal' | 'primary'; + label: string; + onClick: () => void; + disabled?: boolean; +}> = ({ type, label, className = '', onClick, disabled = false }) => { + const buttonClass = type === 'primary' ? primaryClass : normalClass; + const cursorClass = disabled ? 'cursor-default' : 'cursor-pointer'; + + return ( + + ); +}; diff --git a/app/assets/javascripts/components/CircleProgress.tsx b/app/assets/javascripts/components/CircleProgress.tsx new file mode 100644 index 000000000..16fa917dc --- /dev/null +++ b/app/assets/javascripts/components/CircleProgress.tsx @@ -0,0 +1,38 @@ +import { FunctionComponent } from 'preact'; + +export const CircleProgress: FunctionComponent<{ + percent: number; + className?: string; +}> = ({ percent, className = '' }) => { + const size = 16; + const ratioStrokeRadius = 0.25; + const outerRadius = size / 2; + + const radius = outerRadius * (1 - ratioStrokeRadius); + const stroke = outerRadius - radius; + + const circumference = radius * 2 * Math.PI; + const offset = circumference - (percent / 100) * circumference; + + const transition = `transition: 0.35s stroke-dashoffset;`; + const transform = `transform: rotate(-90deg);`; + const transformOrigin = `transform-origin: 50% 50%;`; + const dasharray = `stroke-dasharray: ${circumference} ${circumference};`; + const dashoffset = `stroke-dashoffset: ${offset};`; + const style = `${transition} ${transform} ${transformOrigin} ${dasharray} ${dashoffset}`; + return ( +
+ + + +
+ ); +}; diff --git a/app/assets/javascripts/components/CircleProgressTime.tsx b/app/assets/javascripts/components/CircleProgressTime.tsx new file mode 100644 index 000000000..1f7b6b7d9 --- /dev/null +++ b/app/assets/javascripts/components/CircleProgressTime.tsx @@ -0,0 +1,27 @@ +import { FunctionalComponent } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { CircleProgress } from './CircleProgress'; + +/** + * Circular progress bar which runs in a specified time interval + * @param time - time interval in ms + */ +export const CircleProgressTime: FunctionalComponent<{ time: number }> = ({ + time, +}) => { + const [percent, setPercent] = useState(0); + const interval = time / 100; + useEffect(() => { + const tick = setInterval(() => { + if (percent === 100) { + setPercent(0); + } else { + setPercent(percent + 1); + } + }, interval); + return () => { + clearInterval(tick); + }; + }); + return ; +}; diff --git a/app/assets/javascripts/components/DecoratedInput.tsx b/app/assets/javascripts/components/DecoratedInput.tsx new file mode 100644 index 000000000..f649ab38f --- /dev/null +++ b/app/assets/javascripts/components/DecoratedInput.tsx @@ -0,0 +1,42 @@ +import { FunctionalComponent, ComponentChild } from 'preact'; + +interface Props { + className?: string; + disabled?: boolean; + left?: ComponentChild[]; + right?: ComponentChild[]; + text?: string; +} + +/** + * Input that can be decorated on the left and right side + */ +export const DecoratedInput: FunctionalComponent = ({ + className = '', + disabled = false, + left, + right, + text, +}) => { + const base = + 'rounded py-1.5 px-3 text-input my-1 h-8 flex flex-row items-center gap-4'; + const stateClasses = disabled + ? 'no-border bg-grey-5' + : 'border-solid border-1 border-gray-300'; + const classes = `${base} ${stateClasses} ${className}`; + + return ( +
+ {left} +
+ +
+ {right} +
+ ); +}; diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index e9c9a8b1e..1a30ee761 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -23,6 +23,9 @@ import SettingsIcon from '../../icons/ic-settings.svg'; import StarIcon from '../../icons/ic-star.svg'; import ThemesIcon from '../../icons/ic-themes.svg'; import UserIcon from '../../icons/ic-user.svg'; +import CopyIcon from '../../icons/ic-copy.svg'; +import DownloadIcon from '../../icons/ic-download.svg'; +import InfoIcon from '../../icons/ic-info.svg'; import { toDirective } from './utils'; import { FunctionalComponent } from 'preact'; @@ -52,6 +55,9 @@ const ICONS = { star: StarIcon, themes: ThemesIcon, user: UserIcon, + copy: CopyIcon, + download: DownloadIcon, + info: InfoIcon, }; export type IconType = keyof typeof ICONS; @@ -61,7 +67,7 @@ type Props = { className?: string; }; -export const Icon: FunctionalComponent = ({ type, className }) => { +export const Icon: FunctionalComponent = ({ type, className = '' }) => { const IconComponent = ICONS[type]; return ; }; diff --git a/app/assets/javascripts/components/IconButton.tsx b/app/assets/javascripts/components/IconButton.tsx index 93d826c4e..e15517419 100644 --- a/app/assets/javascripts/components/IconButton.tsx +++ b/app/assets/javascripts/components/IconButton.tsx @@ -1,53 +1,38 @@ import { FunctionComponent } from 'preact'; import { Icon, IconType } from './Icon'; -const ICON_BUTTON_TYPES: { - [type: string]: { className: string }; -} = { - normal: { - className: '', - }, - primary: { - className: 'info', - }, -}; - -export type IconButtonType = keyof typeof ICON_BUTTON_TYPES; - -interface IconButtonProps { +interface Props { /** * onClick - preventDefault is handled within the component */ onClick: () => void; - type: IconButtonType; - className?: string; - iconType: IconType; + icon: IconType; } /** - * CircleButton component with an icon for SPA + * IconButton component with an icon * preventDefault is already handled within the component */ -export const IconButton: FunctionComponent = ({ +export const IconButton: FunctionComponent = ({ onClick, - type, className, - iconType, + icon, }) => { const click = (e: MouseEvent) => { e.preventDefault(); onClick(); }; - const typeProps = ICON_BUTTON_TYPES[type]; return ( ); }; diff --git a/app/assets/javascripts/components/Input.tsx b/app/assets/javascripts/components/Input.tsx new file mode 100644 index 000000000..0955b632c --- /dev/null +++ b/app/assets/javascripts/components/Input.tsx @@ -0,0 +1,22 @@ +import { FunctionalComponent } from 'preact'; + +interface Props { + text?: string; + disabled?: boolean; + className?: string; +} + +export const Input: FunctionalComponent = ({ + className = '', + disabled = false, + text, +}) => { + const base = `rounded py-1.5 px-3 text-input my-1 h-8`; + const stateClasses = disabled + ? 'no-border bg-grey-5' + : 'border-solid border-1 border-gray-300'; + const classes = `${base} ${stateClasses} ${className}`; + return ( + + ); +}; diff --git a/app/assets/javascripts/components/MultipleSelectedNotes.tsx b/app/assets/javascripts/components/MultipleSelectedNotes.tsx index cdf2697ad..a0a974a42 100644 --- a/app/assets/javascripts/components/MultipleSelectedNotes.tsx +++ b/app/assets/javascripts/components/MultipleSelectedNotes.tsx @@ -3,19 +3,21 @@ import { toDirective } from './utils'; import NotesIcon from '../../icons/il-notes.svg'; import { observer } from 'mobx-react-lite'; import { NotesOptionsPanel } from './NotesOptionsPanel'; +import { WebApplication } from '@/ui_models/application'; type Props = { + application: WebApplication; appState: AppState; }; -const MultipleSelectedNotes = observer(({ appState }: Props) => { +const MultipleSelectedNotes = observer(({ application, appState }: Props) => { const count = appState.notes.selectedNotesCount; return (

{count} selected notes

- +
diff --git a/app/assets/javascripts/components/NotesContextMenu.tsx b/app/assets/javascripts/components/NotesContextMenu.tsx index 3afd7df89..292e7265b 100644 --- a/app/assets/javascripts/components/NotesContextMenu.tsx +++ b/app/assets/javascripts/components/NotesContextMenu.tsx @@ -3,12 +3,14 @@ import { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils'; import { observer } from 'mobx-react-lite'; import { NotesOptions } from './NotesOptions'; import { useCallback, useEffect, useRef } from 'preact/hooks'; +import { WebApplication } from '@/ui_models/application'; type Props = { + application: WebApplication; appState: AppState; }; -const NotesContextMenu = observer(({ appState }: Props) => { +const NotesContextMenu = observer(({ application, appState }: Props) => { const { contextMenuOpen, contextMenuPosition, @@ -46,7 +48,11 @@ const NotesContextMenu = observer(({ appState }: Props) => { maxHeight: contextMenuMaxHeight, }} > - +
) : null; }); diff --git a/app/assets/javascripts/components/NotesOptions.tsx b/app/assets/javascripts/components/NotesOptions.tsx index 3f6d07052..3346ff1e4 100644 --- a/app/assets/javascripts/components/NotesOptions.tsx +++ b/app/assets/javascripts/components/NotesOptions.tsx @@ -9,15 +9,33 @@ import { DisclosurePanel, } from '@reach/disclosure'; import { SNNote } from '@standardnotes/snjs/dist/@types'; +import { WebApplication } from '@/ui_models/application'; +import { KeyboardModifier } from '@/services/ioService'; type Props = { + application: WebApplication; appState: AppState; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; onSubmenuChange?: (submenuOpen: boolean) => void; }; +type DeletePermanentlyButtonProps = { + closeOnBlur: Props["closeOnBlur"]; + onClick: () => void; +} + +const DeletePermanentlyButton = ({ + closeOnBlur, + onClick, +}: DeletePermanentlyButtonProps) => ( + +); + export const NotesOptions = observer( - ({ appState, closeOnBlur, onSubmenuChange }: Props) => { + ({ application, appState, closeOnBlur, onSubmenuChange }: Props) => { const [tagsMenuOpen, setTagsMenuOpen] = useState(false); const [tagsMenuPosition, setTagsMenuPosition] = useState<{ top: number; @@ -29,6 +47,7 @@ export const NotesOptions = observer( }); const [tagsMenuMaxHeight, setTagsMenuMaxHeight] = useState('auto'); + const [altKeyDown, setAltKeyDown] = useState(false); const toggleOn = (condition: (note: SNNote) => boolean) => { const notesMatchingAttribute = notes.filter(condition); @@ -59,6 +78,22 @@ export const NotesOptions = observer( } }, [tagsMenuOpen, onSubmenuChange]); + useEffect(() => { + const removeAltKeyObserver = application.io.addKeyObserver({ + modifiers: [KeyboardModifier.Alt], + onKeyDown: () => { + setAltKeyDown(true); + }, + onKeyUp: () => { + setAltKeyDown(false); + } + }); + + return () => { + removeAltKeyObserver(); + }; + }, [application]); + const openTagsMenu = () => { const defaultFontSize = window.getComputedStyle( document.documentElement @@ -235,18 +270,26 @@ export const NotesOptions = observer( Unarchive )} - {notTrashed && ( - - )} + {notTrashed && + (altKeyDown ? ( + { + await appState.notes.deleteNotesPermanently(); + }} + /> + ) : ( + + ))} {trashed && ( <> - + /> + ); +}; diff --git a/app/assets/javascripts/components/Switch.tsx b/app/assets/javascripts/components/Switch.tsx index c504df77c..0a35c2a74 100644 --- a/app/assets/javascripts/components/Switch.tsx +++ b/app/assets/javascripts/components/Switch.tsx @@ -12,7 +12,7 @@ export type SwitchProps = HTMLProps & { checked?: boolean; onChange: (checked: boolean) => void; className?: string; - children: ComponentChildren; + children?: ComponentChildren; }; export const Switch: FunctionalComponent = ( @@ -22,7 +22,9 @@ export const Switch: FunctionalComponent = ( const checked = props.checked ?? checkedState; const className = props.className ?? ''; return ( -