diff --git a/package.json b/package.json index 0c4b07d..d485a33 100755 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "camelcase-keys": "^8.0.2", "crypto-js": "^4.1.1", "date-fns": "^2.29.3", + "deep-equal": "^2.0.5", "formik": "^2.2.9", "group-array": "^1.0.0", "immutability-helper": "^3.1.1", @@ -41,6 +42,7 @@ "devDependencies": { "@types/crypto-js": "^4.1.1", "@types/date-fns": "^2.6.0", + "@types/deep-equal": "^1.0.1", "@types/group-array": "^1.0.1", "@types/openpgp": "^4.4.18", "@types/react": "^18.0.17", diff --git a/src/components/BackupImage.tsx b/src/components/BackupImage.tsx deleted file mode 100644 index 9e0dfa5..0000000 --- a/src/components/BackupImage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, {ReactElement, useState} from "react" - -export interface BackupImageProps - extends Omit< - React.DetailedHTMLProps< - React.ImgHTMLAttributes, - HTMLImageElement - >, - "src" - > { - fallbackSrc: string - src: string -} - -export default function BackupImage({ - fallbackSrc, - src, - ...props -}: BackupImageProps): ReactElement { - const [source, setSource] = useState(src) - - return ( - setSource(fallbackSrc)} /> - ) -} diff --git a/src/components/FaviconImage.tsx b/src/components/FaviconImage.tsx new file mode 100644 index 0000000..ca1c43c --- /dev/null +++ b/src/components/FaviconImage.tsx @@ -0,0 +1,38 @@ +import React, {ReactElement, useState} from "react" + +export interface FaviconImageProps + extends Omit< + React.DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement + >, + "src" + > { + url: string +} + +const getDomain = (url: string): string => { + const {hostname, port} = new URL(url) + return `${hostname}${port ? `:${port}` : ""}` +} + +export default function FaviconImage({ + url, + ...props +}: FaviconImageProps): ReactElement { + const [source, setSource] = useState(`${url}/favicon.ico`) + + return ( + + setSource( + `https://external-content.duckduckgo.com/ip3/${getDomain( + url, + )}.ico`, + ) + } + /> + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 45b07b3..2dfae21 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -28,5 +28,5 @@ export * from "./AliasTypeIndicator" export {default as AliasTypeIndicator} from "./AliasTypeIndicator" export * from "./DecryptionPasswordMissingAlert" export {default as DecryptionPasswordMissingAlert} from "./DecryptionPasswordMissingAlert" -export * from "./BackupImage" -export {default as BackupImage} from "./BackupImage" +export * from "./FaviconImage" +export {default as FaviconImage} from "./FaviconImage" diff --git a/src/route-widgets/AliasDetailRoute/AddWebsiteField.tsx b/src/route-widgets/AliasDetailRoute/AddWebsiteField.tsx new file mode 100644 index 0000000..498b664 --- /dev/null +++ b/src/route-widgets/AliasDetailRoute/AddWebsiteField.tsx @@ -0,0 +1,114 @@ +import * as yup from "yup" +import {useFormik} from "formik" +import {ReactElement} from "react" +import {RiLinkM} from "react-icons/ri" + +import { + Button, + FormGroup, + FormHelperText, + Grid, + InputAdornment, + TextField, +} from "@mui/material" + +import {URL_REGEX} from "~/constants/values" +import {whenEnterPressed} from "~/utils" + +export interface AddWebsiteFieldProps { + onAdd: (website: string) => Promise + isLoading: boolean +} + +interface WebsiteForm { + url: string +} + +const WEBSITE_SCHEMA = yup.object().shape({ + url: yup.string().matches(URL_REGEX, "This URL is invalid."), +}) + +export default function AddWebsiteField({ + onAdd, + isLoading, +}: AddWebsiteFieldProps): ReactElement { + const websiteFormik = useFormik({ + validationSchema: WEBSITE_SCHEMA, + initialValues: { + url: "", + }, + onSubmit: async values => { + const url = (() => { + // Make sure url starts with `http://` or `https://` + if (values.url.startsWith("http://")) { + return values.url + } + if (values.url.startsWith("https://")) { + return values.url + } + return `https://${values.url}` + })() + + const {hostname, protocol, port} = new URL(url) + const baseUrl = `${protocol}//${hostname}${port ? `:${port}` : ""}` + + await onAdd(baseUrl) + websiteFormik.resetForm() + }, + validateOnChange: true, + validateOnBlur: true, + }) + + return ( + + + + + websiteFormik.handleSubmit(), + )} + disabled={websiteFormik.isSubmitting || isLoading} + error={ + websiteFormik.touched.url && + Boolean(websiteFormik.errors.url) + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + {(websiteFormik.touched.url && websiteFormik.errors.url) || + "Add a website to this alias. Used to autofill."} + + + + ) +} diff --git a/src/route-widgets/AliasDetailRoute/AliasDetails.tsx b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx index e93dae0..c6c46fe 100644 --- a/src/route-widgets/AliasDetailRoute/AliasDetails.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx @@ -15,6 +15,11 @@ export interface AliasDetailsProps { alias: Alias | DecryptedAlias } +const getDomain = (url: string): string => { + const {hostname, port} = new URL(url) + return `${hostname}${port ? `:${port}` : ""}` +} + export default function AliasDetails({ alias: aliasValue, }: AliasDetailsProps): ReactElement { @@ -46,24 +51,15 @@ export default function AliasDetails({ - - - - Notes - - - - {encryptionStatus === EncryptionStatus.Available ? ( - - ) : ( - - )} - - + {encryptionStatus === EncryptionStatus.Available ? ( + + ) : ( + + )} @@ -72,24 +68,23 @@ export default function AliasDetails({ Settings - - - These settings apply to this alias only. You can - either set a value manually or refer to your - defaults settings. Note that this does change in - behavior. When you set a value to refer to your - default setting, the alias will always use the - latest value. So when you change your default - setting, the alias will automatically use the new - value. - - + + + These settings apply to this alias only. You can + either set a value manually or refer to your default + settings. Note that this does change in behavior. + When you set a value to refer to your default + setting, the alias will always use the latest value. + So when you change your default setting, the alias + will automatically use the new value. + + diff --git a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx index 451f62e..a98063b 100644 --- a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx @@ -1,18 +1,17 @@ import * as yup from "yup" import {TiDelete} from "react-icons/ti" import {AxiosError} from "axios" -import {ReactElement, useContext} from "react" -import {MdEditCalendar} from "react-icons/md" -import {RiLinkM, RiStickyNoteFill} from "react-icons/ri" +import {ReactElement, useContext, useMemo, useState} from "react" +import {MdCheckCircle, MdEditCalendar} from "react-icons/md" +import {RiStickyNoteFill} from "react-icons/ri" import {FieldArray, FormikProvider, useFormik} from "formik" +import {FaPen} from "react-icons/fa" +import deepEqual from "deep-equal" import format from "date-fns/format" import update from "immutability-helper" import {useMutation} from "@tanstack/react-query" import { - Button, - FormGroup, - FormHelperText, Grid, IconButton, InputAdornment, @@ -26,11 +25,11 @@ import { Typography, } from "@mui/material" -import {URL_REGEX} from "~/constants/values" -import {parseFastAPIError, whenEnterPressed} from "~/utils" -import {BackupImage, ErrorSnack, SuccessSnack} from "~/components" +import {parseFastAPIError} from "~/utils" +import {ErrorSnack, FaviconImage, SuccessSnack} from "~/components" import {Alias, AliasNote, DecryptedAlias} from "~/server-types" import {UpdateAliasData, updateAlias} from "~/apis" +import AddWebsiteField from "~/route-widgets/AliasDetailRoute/AddWebsiteField" import AuthContext from "~/AuthContext/AuthContext" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" @@ -48,10 +47,6 @@ interface Form { detail?: string } -interface WebsiteForm { - url: string -} - const SCHEMA = yup.object().shape({ personalNotes: yup.string(), websites: yup.array().of( @@ -61,14 +56,6 @@ const SCHEMA = yup.object().shape({ }), ), }) -const WEBSITE_SCHEMA = yup.object().shape({ - url: yup.string().matches(URL_REGEX, "This URL is invalid."), -}) - -const getDomain = (url: string): string => { - const {hostname, port} = new URL(url) - return `${hostname}${port ? `:${port}` : ""}` -} export default function AliasNotesForm({ id, @@ -91,12 +78,16 @@ export default function AliasNotesForm({ onChanged(newAlias as any as DecryptedAlias) }, }) - const formik = useFormik
({ - validationSchema: SCHEMA, - initialValues: { + const initialValues = useMemo( + () => ({ personalNotes: notes.data.personalNotes, websites: notes.data.websites, - }, + }), + [notes.data.personalNotes, notes.data.websites], + ) + const formik = useFormik({ + validationSchema: SCHEMA, + initialValues, onSubmit: async (values, {setErrors}) => { try { const newNotes = update(notes, { @@ -121,187 +112,244 @@ export default function AliasNotesForm({ } }, }) - const websiteFormik = useFormik({ - validationSchema: WEBSITE_SCHEMA, - initialValues: { - url: "", - }, - onSubmit: async values => { - const url = (() => { - // Make sure url starts with `http://` or `https://` - if (values.url.startsWith("http://")) { - return values.url - } - if (values.url.startsWith("https://")) { - return values.url - } - return `https://${values.url}` - })() - const {hostname, protocol, port} = new URL(url) - const baseUrl = `${protocol}//${hostname}${port ? `:${port}` : ""}` - - websiteFormik.resetForm() - await formik.setFieldValue( - "websites", - [ - ...formik.values.websites, - { - url: baseUrl, - createdAt: new Date(), - }, - ], - true, - ) - await formik.submitForm() - }, - validateOnChange: true, - validateOnBlur: true, - }) + const [isInEditMode, setIsInEditMode] = useState(false) return ( <> - - {notes.data.createdAt && ( - - + + + + + + Notes + + + + { + if ( + isInEditMode && + !deepEqual( + initialValues, + formik.values, + { + strict: true, + }, + ) + ) { + await formik.submitForm() + } + + setIsInEditMode(!isInEditMode) + }} + > + {isInEditMode ? ( + + ) : ( + + )} + + + + + + + {notes.data.createdAt && ( - - - - - - {format(notes.data.createdAt, "Pp")} + + + + + + + {format( + notes.data.createdAt, + "Pp", + )} + + + + + + )} + + + + + Personal Notes - + + + {isInEditMode ? ( + + + + ), + }} + /> + ) : ( + + {notes.data.personalNotes} + + )} + + + + + + + + Websites + + + {isInEditMode ? ( + + { + await formik.setFieldValue( + "websites", + [ + ...formik.values + .websites, + { + url: website, + }, + ], + ) + }} + isLoading={formik.isSubmitting} + /> + + ( + + {formik.values.websites.map( + ( + website, + index, + ) => ( + + + + + + { + website.url + } + + + { + arrayHelpers.remove( + index, + ) + }} + > + + + + + ), + )} + + )} + /> + + + ) : ( + + {notes.data.websites.length ? ( + + {notes.data.websites.map( + website => ( + + + + + + { + website.url + } + + + ), + )} + + ) : ( + + You haven't used this + alias on any website yet. + + )} + + )} - )} - - formik.submitForm()} - disabled={formik.isSubmitting} - error={ - formik.touched.personalNotes && - Boolean(formik.errors.personalNotes) - } - helperText={ - (formik.touched.personalNotes && - formik.errors.personalNotes) || - "You can enter personal notes for this alias here. Notes are encrypted." - } - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - - - - websiteFormik.handleSubmit(), - )} - disabled={websiteFormik.isSubmitting} - error={ - websiteFormik.touched.url && - Boolean(websiteFormik.errors.url) - } - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - - {(websiteFormik.touched.url && - websiteFormik.errors.url) || - "Add a website to this alias. Used to autofill."} - - - - ( - - {formik.values.websites.map( - (website, index) => ( - - - - - - {website.url} - - - { - arrayHelpers.remove( - index, - ) - await formik.submitForm() - }} - > - - - - - ), - )} - - )} - /> - diff --git a/src/routes/AuthenticatedRoute.tsx b/src/routes/AuthenticatedRoute.tsx index 8e955fb..0fdbe45 100644 --- a/src/routes/AuthenticatedRoute.tsx +++ b/src/routes/AuthenticatedRoute.tsx @@ -40,7 +40,7 @@ export default function AuthenticatedRoute(): ReactElement { justifyContent="space-between" alignItems="center" > - + - +