diff --git a/app/hooks/help/index.tsx b/app/hooks/help/index.tsx new file mode 100644 index 0000000000..036aefc9a0 --- /dev/null +++ b/app/hooks/help/index.tsx @@ -0,0 +1,78 @@ +import React, { + createContext, useCallback, useContext, useState, +} from 'react'; + +import { BeaconProps, HelpContextProps, HelpProviderProps } from './types'; + +const HelpContext = createContext({ + active: false, + onActive: (active) => { console.info(active); }, + beacons: {}, + addBeacon: (beacon) => { console.info(beacon); }, + removeBeacon: (beacon) => { console.info(beacon); }, +}); + +// Hook for child components to get the toast object ... +// and re-render when it changes. +export const useHelp = () => { + const ctx = useContext(HelpContext); + + if (!ctx) { + throw Error( + 'The `useHelp` hook must be called from a descendent of the `HelpProvider`.', + ); + } + + return { + active: ctx.active, + onActive: ctx.onActive, + beacons: ctx.beacons, + addBeacon: ctx.addBeacon, + removeBeacon: ctx.removeBeacon, + }; +}; + +// Provider component that wraps your app and makes toast object ... +// ... available to any child component that calls useHelp(). +export function HelpProvider({ + children, +}: HelpProviderProps) { + const [active, setActive] = useState(false); + const [beacons, setBeacons] = useState>({}); + + const onActive = useCallback((a: boolean) => { + return setActive(a); + }, [setActive]); + + const addBeacon = useCallback(({ id, state, update }) => { + setBeacons((prevBeacons) => { + return { + ...prevBeacons, + [id]: { + id, + state, + update, + }, + }; + }); + }, []); + + const removeBeacon = useCallback((id: string) => { + const { [id]: omitted, ...rest } = beacons; + setBeacons(rest); + }, [beacons]); + + return ( + + {children} + + ); +} diff --git a/app/hooks/help/types.ts b/app/hooks/help/types.ts new file mode 100644 index 0000000000..27b7796443 --- /dev/null +++ b/app/hooks/help/types.ts @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +export interface BeaconProps { + id: string; + update: () => void; + state: any; +} + +export interface HelpContextProps { + active: boolean; + onActive: (active: boolean) => void; + beacons: Record, + addBeacon: (beacon: Record) => void, + removeBeacon: (beaconId: string) => void, +} + +export interface HelpProviderProps { + children: ReactNode; +} diff --git a/app/layout/help/beacon/component.tsx b/app/layout/help/beacon/component.tsx new file mode 100644 index 0000000000..8deacb9153 --- /dev/null +++ b/app/layout/help/beacon/component.tsx @@ -0,0 +1,146 @@ +import React, { + ReactNode, ReactElement, cloneElement, useState, + useEffect, + useCallback, + useRef, +} from 'react'; +import { createPortal } from 'react-dom'; + +import cx from 'classnames'; + +import { useHelp } from 'hooks/help'; +import { usePopper } from 'react-popper'; +import { useResizeDetector } from 'react-resize-detector'; + +import { AnimatePresence } from 'framer-motion'; + +import Tooltip from 'components/tooltip'; +import HelpTooltip from 'layout/help/tooltip'; +import HelpSpotlight from 'layout/help/spotlight'; + +const flipModifier = { + name: 'flip', + enabled: false, +}; + +const hideModifier = { + name: 'hide', + enabled: true, +}; +export interface HelpBeaconProps { + id: string; + title: string; + subtitle: string; + content: ReactNode; + children: ReactElement; +} + +export const HelpBeacon: React.FC = ({ + id, + title, + subtitle, + content, + children, +}: HelpBeaconProps) => { + const { active, beacons, addBeacon } = useHelp(); + const [visible, setVisible] = useState(false); + const childrenRef = useRef(null); + const [beaconRef, setBeaconRef] = useState(null); + + const CHILDREN = cloneElement(children, { + ref: childrenRef, + }); + + const onResize = useCallback(() => { + Object.keys(beacons).forEach((k) => { + const b = beacons[k]; + if (b.update) b.update(); + }); + }, [beacons]); + + // 'usePopper' + const { + styles, attributes, state, update, + } = usePopper(childrenRef.current, beaconRef, { + placement: 'top-start', + modifiers: [ + flipModifier, + hideModifier, + ], + }); + + useResizeDetector({ + targetRef: childrenRef, + onResize, + }); + + useEffect(() => { + addBeacon({ + id, + state, + update, + }); + }, [active, addBeacon, id, state, update, childrenRef, beaconRef]); + + return ( + <> + { + setVisible(false); + }} + content={( + + )} + > + {CHILDREN} + + + {typeof window !== 'undefined' && active && !visible && createPortal( +
setBeaconRef(el))} + className={cx({ + 'z-50': true, + 'visible pointer-events-auto': active, + 'invisible pointer-events-none': !active || attributes?.popper?.['data-popper-reference-hidden'] || attributes?.popper?.['data-popper-escaped'], + })} + style={styles.popper} + {...attributes.popper} + > + +
, + document?.body, + )} + + {typeof window !== 'undefined' && active && createPortal( + + {visible && ( + + )} + , + document?.body, + )} + + ); +}; + +export default HelpBeacon; diff --git a/app/layout/help/beacon/index.ts b/app/layout/help/beacon/index.ts new file mode 100644 index 0000000000..b404d7fd44 --- /dev/null +++ b/app/layout/help/beacon/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/app/layout/help/button/component.tsx b/app/layout/help/button/component.tsx new file mode 100644 index 0000000000..90f5449e87 --- /dev/null +++ b/app/layout/help/button/component.tsx @@ -0,0 +1,101 @@ +import React, { useCallback } from 'react'; +import cx from 'classnames'; + +import Icon from 'components/icon'; + +import { motion } from 'framer-motion'; + +import { useHelp } from 'hooks/help'; + +import HELP_SVG from 'svgs/ui/help.svg?sprite'; + +export const HelpButton = () => { + const { active, onActive } = useHelp(); + + const onToggleActive = useCallback((e) => { + e.preventDefault(); + onActive(!active); + }, [active, onActive]); + + return ( +
+
+
+ + Activate guide +
+
+
+ ); +}; + +export default HelpButton; diff --git a/app/layout/help/button/index.ts b/app/layout/help/button/index.ts new file mode 100644 index 0000000000..b404d7fd44 --- /dev/null +++ b/app/layout/help/button/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/app/layout/help/spotlight/component.tsx b/app/layout/help/spotlight/component.tsx new file mode 100644 index 0000000000..bd1f241d3b --- /dev/null +++ b/app/layout/help/spotlight/component.tsx @@ -0,0 +1,111 @@ +import React, { + MutableRefObject, useEffect, useRef, useState, + useCallback, +} from 'react'; + +import { motion } from 'framer-motion'; + +interface HelpSpotlightProps { + childrenRef: MutableRefObject; +} + +export const HelpSpotlight = ({ + childrenRef, +}: HelpSpotlightProps) => { + const [canvasSize, setCanvasSize] = useState({ + width: 0, + height: 0, + }); + + const canvasRef = useRef(null); + + const getCanvasSize = () => { + const { width, height } = document.body.getBoundingClientRect(); + + setCanvasSize({ + width, height, + }); + }; + + const drawBackground = useCallback((CTX, reference) => { + const { width: canvaswidth, height: canvasheight } = canvasSize; + + if (reference.current) { + const { + top, left, width, height, + } = reference.current.getBoundingClientRect(); + + const gradient = CTX.createRadialGradient( + left + width / 2, + top + height / 2, + width, + left + width / 2, + top + height / 2, + height, + ); + + // Add three color stops + gradient.addColorStop(0, 'rgba(0,0,0,0.75)'); + gradient.addColorStop(1, 'rgba(0,0,0,0)'); + CTX.clearRect(0, 0, canvaswidth, canvasheight); + + CTX.beginPath(); + CTX.rect(0, 0, canvaswidth, canvasheight); + // CTX.fillStyle = 'rgba(0,0,0,0.75)'; + CTX.fillStyle = gradient; + CTX.fill(); + } + }, [canvasSize]); + + const drawHighlight = useCallback((CTX, reference) => { + if (reference.current) { + const { + top, left, width, height, + } = reference.current.getBoundingClientRect(); + + CTX.globalCompositeOperation = 'destination-out'; + CTX.beginPath(); + CTX.rect(left, top, width, height); + CTX.fillStyle = 'white'; + CTX.fill(); + } + }, []); + + const updateCanvas = useCallback(() => { + const CTX = canvasRef.current.getContext('2d'); + CTX.save(); + + drawBackground(CTX, childrenRef); + drawHighlight(CTX, childrenRef); + + CTX.restore(); + }, [childrenRef, drawBackground, drawHighlight]); + + useEffect(() => { + getCanvasSize(); + + window.addEventListener('resize', getCanvasSize); + + return () => { + window.removeEventListener('resize', getCanvasSize); + }; + }, []); + + useEffect(() => { + updateCanvas(); + }, [updateCanvas]); + + return ( + + ); +}; + +export default HelpSpotlight; diff --git a/app/layout/help/spotlight/index.ts b/app/layout/help/spotlight/index.ts new file mode 100644 index 0000000000..b404d7fd44 --- /dev/null +++ b/app/layout/help/spotlight/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/app/layout/help/tooltip/component.tsx b/app/layout/help/tooltip/component.tsx new file mode 100644 index 0000000000..5827f4c314 --- /dev/null +++ b/app/layout/help/tooltip/component.tsx @@ -0,0 +1,43 @@ +import React, { ReactNode } from 'react'; + +import Icon from 'components/icon'; + +import HELP_2_SVG from 'svgs/ui/help-2.svg?sprite'; + +interface HelpTooltipProps { + title: string; + subtitle: string; + content: ReactNode, +} + +export const HelpTooltip = ({ + title, + subtitle, + content, +}: HelpTooltipProps) => { + return ( +
+
+
+ +
+ +
+

{title}

+

{subtitle}

+
+
+ +
+ {content} +
+
+ ); +}; + +export default HelpTooltip; diff --git a/app/layout/help/tooltip/index.ts b/app/layout/help/tooltip/index.ts new file mode 100644 index 0000000000..b404d7fd44 --- /dev/null +++ b/app/layout/help/tooltip/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/app/layout/projects/all/list/component.tsx b/app/layout/projects/all/list/component.tsx index c88451c336..8a2589f03d 100644 --- a/app/layout/projects/all/list/component.tsx +++ b/app/layout/projects/all/list/component.tsx @@ -4,6 +4,7 @@ import cx from 'classnames'; import { useSelector } from 'react-redux'; import Wrapper from 'layout/wrapper'; +import HelpBeacon from 'layout/help/beacon'; import Loading from 'components/loading'; import ConfirmationPrompt from 'components/confirmation-prompt'; @@ -70,34 +71,49 @@ export const ProjectsList: React.FC = () => { /> {isFetched && !!data.length && ( -
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. + Beatae ratione cumque in nobis fugiat, + neque ullam aliquam, commodi dolorem unde inventore eaque, + dolorum eveniet! Corrupti voluptatum molestias quaerat voluptatem ipsa. +
+ )} > - {data.map((d) => { - return ( - { - setDelete(d); - }} - /> - ); - })} - - setDelete(null)} - onDismiss={() => setDelete(null)} - /> - - +
+ {data.map((d) => { + return ( + { + setDelete(d); + }} + /> + ); + })} + + setDelete(null)} + onDismiss={() => setDelete(null)} + /> + +
+ )} {isFetched && !data.length && ( diff --git a/app/layout/projects/all/toolbar/component.tsx b/app/layout/projects/all/toolbar/component.tsx index d2772bf60d..41a4f6d8e0 100644 --- a/app/layout/projects/all/toolbar/component.tsx +++ b/app/layout/projects/all/toolbar/component.tsx @@ -9,8 +9,10 @@ import Link from 'next/link'; import Search from 'components/search'; import Button from 'components/button'; import Icon from 'components/icon'; +import HelpBeacon from 'layout/help/beacon'; import PLUS_SVG from 'svgs/ui/plus.svg?sprite'; +import UPLOAD_SVG from 'svgs/ui/upload.svg?sprite'; export interface ProjectsToolbarProps { @@ -22,28 +24,80 @@ export const ProjectsToolbar: React.FC = () => { return ( -
-
- { dispatch(setSearch(value)); }} - /> -
+
+ + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Beatae ratione cumque in nobis fugiat, + neque ullam aliquam, commodi dolorem unde inventore eaque, + dolorum eveniet! Corrupti voluptatum molestias quaerat voluptatem ipsa. +
+ )} + > +
+ { dispatch(setSearch(value)); }} + /> +
+ + +
+ + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Beatae ratione cumque in nobis fugiat, + neque ullam aliquam, commodi dolorem unde inventore eaque, + dolorum eveniet! Corrupti voluptatum molestias quaerat voluptatem ipsa. +
+ )} + > + + - + )} + > + +
diff --git a/app/layout/projects/new/form/component.tsx b/app/layout/projects/new/form/component.tsx index 5d83ba10a6..2e75f134a5 100644 --- a/app/layout/projects/new/form/component.tsx +++ b/app/layout/projects/new/form/component.tsx @@ -100,7 +100,7 @@ const ProjectForm: React.FC = () => { className="flex flex-col justify-between flex-grow w-full overflow-hidden" >
-
+
diff --git a/app/layout/projects/new/map/component.tsx b/app/layout/projects/new/map/component.tsx index 4dcb0e77d5..ca88d5f902 100644 --- a/app/layout/projects/new/map/component.tsx +++ b/app/layout/projects/new/map/component.tsx @@ -97,7 +97,10 @@ export const ProjectNewMap: React.FC = ({ }; return ( -
+
= () => { >