diff --git a/apps/extension/src/contentscript/multitable-panel/components/dropdown.tsx b/apps/extension/src/contentscript/multitable-panel/components/dropdown.tsx index d4f42157..85129a98 100644 --- a/apps/extension/src/contentscript/multitable-panel/components/dropdown.tsx +++ b/apps/extension/src/contentscript/multitable-panel/components/dropdown.tsx @@ -1,22 +1,5 @@ -import React, { DetailedHTMLProps, FC, HTMLAttributes, useMemo, useState } from 'react' +import React, { DetailedHTMLProps, FC, HTMLAttributes, useMemo } from 'react' import { - AuthorMutation, - AvalibleArrowBlock, - AvalibleArrowLable, - AvalibleLable, - AvalibleLableBlock, - AvalibleMutations, - ButtonBack, - ButtonListBlock, - ButtonMutation, - ImageBlock, - InputBlock, - InputIconWrapper, - InputInfoWrapper, - InputMutation, - ListMutations, - MutationsList, - MutationsListWrapper, OpenList, OpenListDefault, SelectedMutationBlock, @@ -26,40 +9,11 @@ import { StarSelectedMutationWrapper, WrapperDropdown, } from '../assets/styles-dropdown' -import { - AvailableIcon, - Back, - IconDropdown, - Mutate, - StarMutationList, - StarMutationListDefault, - StarSelectMutation, - StarSelectMutationDefault, -} from '../assets/vectors' - -import { useDeleteLocalMutation, useMutableWeb } from '@mweb/engine' -import { EntitySourceType, MutationWithSettings } from '@mweb/backend' -import defaultIcon from '../assets/images/default.svg' -import { Image } from './image' +import { IconDropdown, StarSelectMutation, StarSelectMutationDefault } from '../assets/vectors' +import { useMutableWeb } from '@mweb/engine' +import { EntitySourceType } from '@mweb/backend' import { Badge } from './badge' -import { ArrowDownOutlined, DeleteOutlined, EyeFilled, EyeInvisibleFilled } from '@ant-design/icons' -import styled from 'styled-components' -import { ModalDelete } from './modal-delete' - -const ModalConfirmBackground = styled.div` - position: absolute; - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - min-height: 190px; - top: 0; - left: 0; - background-color: rgba(255, 255, 255, 0.7); - border-radius: inherit; - z-index: 1; -` +import { EyeFilled, EyeInvisibleFilled } from '@ant-design/icons' export type DropdownProps = DetailedHTMLProps, HTMLDivElement> & { isVisible: boolean @@ -67,24 +21,15 @@ export type DropdownProps = DetailedHTMLProps, HT onMutateButtonClick: () => void } -export const Dropdown: FC = ({ - isVisible, - onVisibilityChange, - onMutateButtonClick, -}: DropdownProps) => { +export const Dropdown: FC = ({ isVisible, onVisibilityChange }: DropdownProps) => { const { mutations, selectedMutation, favoriteMutationId, setFavoriteMutation, - switchMutation, switchPreferredSource, - getPreferredSource, - removeMutationFromRecents, } = useMutableWeb() - const { deleteLocalMutation } = useDeleteLocalMutation() - const recentlyUsedMutations = useMemo( () => Object.groupBy( @@ -104,25 +49,6 @@ export const Dropdown: FC = ({ [mutations] ) - const [isAccordeonExpanded, setIsAccordeonExpanded] = useState( - Object.keys(recentlyUsedMutations).length === 0 - ) - const [mutationIdToDelete, setMutationIdToDelete] = useState(null) - - const unusedMutations = useMemo( - () => - Object.groupBy( - mutations.filter((mut) => !mut.settings.lastUsage), - (mut) => mut.id - ), - [mutations] - ) - - const handleMutationClick = (mutationId: string) => { - onVisibilityChange(false) - switchMutation(mutationId) - } - const handleSwitchSourceClick: React.MouseEventHandler = (e) => { e.stopPropagation() // do not open dropdown @@ -136,29 +62,10 @@ export const Dropdown: FC = ({ switchPreferredSource(selectedMutation.id, source) } - // todo: mock - const handleAccordeonClick = () => { - setIsAccordeonExpanded((val) => !val) - } - - const handleMutateButtonClick = () => { - onVisibilityChange(false) - onMutateButtonClick() - } - const handleFavoriteButtonClick = (mutationId: string) => { setFavoriteMutation(mutationId === favoriteMutationId ? null : mutationId) } - const handleOriginalButtonClick = async () => { - onVisibilityChange(false) - switchMutation(null) - } - - const handleRemoveFromRecentlyUsedClick = async (mut: MutationWithSettings) => { - removeMutationFromRecents(mut.id) - } - return ( = ({ )} - - {isVisible && ( - - - {} to Original - - Mutate {} -
-
-
- - {Object.keys(recentlyUsedMutations).length > 0 ? ( - - {Object.values(recentlyUsedMutations).map((muts) => { - if (!muts || !muts.length) return null - const recentlyUsedSource = getPreferredSource(muts[0].id) - const mut = - muts.find( - (mut) => mut.source === (recentlyUsedSource ?? EntitySourceType.Local) - ) ?? muts[0] - return ( - - - - - handleMutationClick(mut.id)}> - {/* todo: mocked classname */} - - {mut.metadata ? mut.metadata.name : ''}{' '} - {recentlyUsedMutations[mut.id]?.length === 2 ? ( - mut.source === EntitySourceType.Local ? ( - - ) : ( - - ) - ) : mut.source === EntitySourceType.Local ? ( - - ) : null} - - {/* todo: mocked classname */} - {mut.authorId ? ( - - by {mut.authorId} - - ) : null} - - {/* todo: mocked */} - - {mut.id === favoriteMutationId ? ( - handleFavoriteButtonClick(mut.id)}> - - - ) : mut.id === selectedMutation?.id ? ( - handleFavoriteButtonClick(mut.id)}> - - - ) : null} - - {mut.source === EntitySourceType.Local ? ( - setMutationIdToDelete(mut.id)}> - - - ) : mut.id !== selectedMutation?.id && - mut.id !== favoriteMutationId && - !getPreferredSource(mut.id) ? ( - handleRemoveFromRecentlyUsedClick(mut)}> - - - ) : null} - - ) - })} -
-
- ) : null} - - {Object.keys(unusedMutations).length > 0 ? ( - - - available - {/* todo: mock */} - - - {Object.keys(unusedMutations).length} mutations - - - -
-
- - {isAccordeonExpanded ? ( -
- {Object.values(unusedMutations).map((muts) => { - if (!muts) return null - const [mut] = muts - - return ( - handleMutationClick(mut.id)} - className="avalibleMutationsInput" - > - - - - - {mut.metadata ? mut.metadata.name : ''} - {mut.authorId ? ( - by {mut.authorId} - ) : null} - - - ) - })} -
- ) : null} -
- ) : null} -
- - {mutationIdToDelete && ( - - { - await deleteLocalMutation(mutationIdToDelete) - if (mutationIdToDelete === favoriteMutationId) - handleFavoriteButtonClick(mutationIdToDelete) - setMutationIdToDelete(null) - }} - onCloseCurrent={() => setMutationIdToDelete(null)} - /> - - )} -
- )}
) } diff --git a/apps/extension/src/contentscript/multitable-panel/multitable-panel.tsx b/apps/extension/src/contentscript/multitable-panel/multitable-panel.tsx index ce57cea8..80feb7ba 100644 --- a/apps/extension/src/contentscript/multitable-panel/multitable-panel.tsx +++ b/apps/extension/src/contentscript/multitable-panel/multitable-panel.tsx @@ -172,7 +172,13 @@ export const MultitablePanel: FC = ({ eventEmitter }) => { return ( <> - + {isModalOpen ? ( networkId: NearNetworkId + setOpen: React.Dispatch> + open: boolean + handleMutateButtonClick: () => void }) { const { selectedMutation, mutationApps } = useMutableWeb() const trackingRefs = new Set>() trackingRefs.add(notchRef) return ( <> {mutationApps.map((app) => ( diff --git a/libs/shared-components/package.json b/libs/shared-components/package.json index e1f0ede1..89a2f21e 100644 --- a/libs/shared-components/package.json +++ b/libs/shared-components/package.json @@ -33,6 +33,7 @@ "@uiw/codemirror-extensions-langs": "^4.23.3", "@uiw/react-codemirror": "^4.23.3", "antd": "^5.18.3", + "@ant-design/icons": "5.5.1", "codemirror": "^6.0.1", "ethereum-blockies-base64": "^1.0.2", "json-stringify-deterministic": "^1.0.12", diff --git a/libs/shared-components/src/helpers.ts b/libs/shared-components/src/helpers.ts new file mode 100644 index 00000000..7aba65a3 --- /dev/null +++ b/libs/shared-components/src/helpers.ts @@ -0,0 +1,77 @@ +import { MutationDto } from '@mweb/backend' + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +export function isObject(item: unknown) { + return item && typeof item === 'object' && !Array.isArray(item) +} + +/** + * Deep clones object. + * @param object + */ +export const cloneDeep = (obj: T): T => JSON.parse(JSON.stringify(obj)) + +/** + * Deep compare two object. + * @param a + * @param b + */ +export const compareDeep = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) + +/** + * Compare two Mutations. + * @param m1 + * @param m2 + */ +export const compareMutations = (m1: MutationDto, m2: MutationDto): boolean => + !( + m1.id !== m2.id || + !compareDeep(m1.targets, m2.targets) || + !compareDeep(m1.metadata, m2.metadata) || + m1.apps.length !== m2.apps.length || + !compareDeep(m1.apps.sort(), m2.apps.sort()) + ) + +export const ipfsUpload = async (f: File) => { + const res = await fetch('https://ipfs.near.social/add', { + method: 'POST', + headers: { + Accept: 'application/json', + }, + body: f, + }) + return (await res.json()).cid +} + +/** + * Deep merge two objects. + * @param target + * @param source + */ +export function mergeDeep(target: T, source: Partial): T { + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mergeDeep(target[key], source[key]) + } else { + Object.assign(target, { [key]: source[key] }) + } + } + } + + return target +} + +export function isValidSocialIdCharacters(value: string): boolean { + return /^[a-zA-Z0-9_.\-/]*$/.test(value) +} + +export const generateRandomHex = (size: number) => + [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('') diff --git a/libs/shared-components/src/hooks/use-escape.ts b/libs/shared-components/src/hooks/use-escape.ts new file mode 100644 index 00000000..c9fc2d13 --- /dev/null +++ b/libs/shared-components/src/hooks/use-escape.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react' + +export const useEscape = (callback: () => void) => { + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + callback() + } + } + + document.addEventListener('keydown', handleEsc, false) + + return () => { + document.removeEventListener('keydown', handleEsc, false) + } + }, [callback]) +} diff --git a/libs/shared-components/src/mini-overlay/assets/icons/index.ts b/libs/shared-components/src/mini-overlay/assets/icons/index.ts index e424919c..7932305c 100644 --- a/libs/shared-components/src/mini-overlay/assets/icons/index.ts +++ b/libs/shared-components/src/mini-overlay/assets/icons/index.ts @@ -11,6 +11,7 @@ import Copy from './copy' import Disconnect from './disconnect' import OpenOverlay from './open-overlay' import OpenOverlayWithCircle from './open-overlay-with-circle' +import Logo from './logo' export { MutationFallbackIcon, @@ -26,4 +27,5 @@ export { Disconnect, OpenOverlay, OpenOverlayWithCircle, + Logo, } diff --git a/libs/shared-components/src/mini-overlay/assets/icons/logo.tsx b/libs/shared-components/src/mini-overlay/assets/icons/logo.tsx new file mode 100644 index 00000000..e8824c85 --- /dev/null +++ b/libs/shared-components/src/mini-overlay/assets/icons/logo.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +const Logo = () => ( + + + + + + + +) + +export default Logo diff --git a/libs/shared-components/src/mini-overlay/index.tsx b/libs/shared-components/src/mini-overlay/index.tsx index afa91ee6..80ad30f2 100644 --- a/libs/shared-components/src/mini-overlay/index.tsx +++ b/libs/shared-components/src/mini-overlay/index.tsx @@ -157,6 +157,9 @@ interface IMiniOverlayProps extends Partial { mutationApps: AppWithSettings[] children: ReactElement trackingRefs?: Set> + setOpen: React.Dispatch> + open: boolean + handleMutateButtonClick: () => void } export const AppSwitcher: FC = ({ app, enableApp, disableApp, isLoading }) => { @@ -208,10 +211,12 @@ export const MiniOverlay: FC = ({ nearNetwork, children, trackingRefs, + setOpen, + open, + handleMutateButtonClick, }) => { const loggedInAccountId: string = useAccountId() // ToDo: check type const overlayRef = useRef(null) - const [open, setOpen] = useState(false) return ( @@ -257,6 +262,7 @@ export const MiniOverlay: FC = ({ disconnectWallet={disconnectWallet!} nearNetwork={nearNetwork!} trackingRefs={trackingRefs} + handleMutateButtonClick={handleMutateButtonClick} /> diff --git a/libs/shared-components/src/mini-overlay/overlay-wrapper.tsx b/libs/shared-components/src/mini-overlay/overlay-wrapper.tsx index 7b11dedf..24f4bf26 100644 --- a/libs/shared-components/src/mini-overlay/overlay-wrapper.tsx +++ b/libs/shared-components/src/mini-overlay/overlay-wrapper.tsx @@ -2,8 +2,9 @@ import React, { FC, useRef, useState } from 'react' import styled from 'styled-components' import { Drawer, Space, Button } from 'antd' import { Typography } from 'antd' +import MultitablePanel from '../multitable-panel' import NotificationFeed from '../notifications/notification-feed' -import { Close as CloseIcon } from './assets/icons' +import { Close as CloseIcon, Logo as LogoIcon } from './assets/icons' import Profile from './profile' import { IWalletConnect } from './types' const { Title, Text } = Typography @@ -61,6 +62,12 @@ const OverlayWrapperBlock = styled.div<{ $isApps: boolean }>` justify-content: space-between; } } + + .notifyWrapper { + &::-webkit-scrollbar { + width: 0; + } + } ` const OverlayContent = styled.div<{ $isOpen: boolean }>` @@ -106,17 +113,25 @@ const OverlayContent = styled.div<{ $isOpen: boolean }>` .ant-drawer-header { border-bottom: none; + background: #2b2a33; padding: 10px; - padding-bottom: 0; h3 { margin-bottom: 0; + color: #fff; + align-items: center; + display: inline-flex; + gap: 10px; } .ant-space { width: 100%; justify-content: space-between; } + + button { + padding: 5px; + } } .ant-drawer-body { @@ -129,9 +144,33 @@ const Body = styled.div` height: 100%; position: relative; overflow: hidden; + overflow-y: auto; &::-webkit-scrollbar { - width: 0; + cursor: pointer; + width: 4px; + } + + &::-webkit-scrollbar-track { + margin-bottom: 10px; + margin-top: 40px; + background: rgb(244 244 244); + background: linear-gradient( + 90deg, + rgb(244 244 244 / 0%) 10%, + rgb(227 227 227 / 100%) 50%, + rgb(244 244 244 / 0%) 90% + ); + } + + &::-webkit-scrollbar-thumb { + width: 4px; + height: 2px; + background: #384bff; + border-radius: 2px; + box-shadow: + 0 2px 6px rgb(0 0 0 / 9%), + 0 2px 2px rgb(38 117 209 / 4%); } ` @@ -192,6 +231,7 @@ export interface IOverlayWrapperProps extends IWalletConnect { modalContainerRef: React.RefObject trackingRefs?: Set> openCloseNotificationPage: React.Dispatch> + handleMutateButtonClick: () => void } const OverlayWrapper: FC = ({ @@ -204,6 +244,7 @@ const OverlayWrapper: FC = ({ nearNetwork, modalContainerRef, trackingRefs, + handleMutateButtonClick, }) => { const overlayRef = useRef(null) const [waiting, setWaiting] = useState(false) @@ -226,70 +267,15 @@ const OverlayWrapper: FC = ({ - {loggedInAccountId ? ( - - - Mutable Web - - - - - ) : ( - - - - Sign in - - - - - - - To see personalized notifications, you must sign in by connecting your wallet. - - - {' '} - - {waiting ? ( -
- ) : ( - <> - - Connect - - )} -
-
-
- )} + + + <LogoIcon /> Mutable Web + + + + } placement="right" @@ -307,7 +293,6 @@ const OverlayWrapper: FC = ({ {loggedInAccountId ? ( <> - {' '} { @@ -319,6 +304,11 @@ const OverlayWrapper: FC = ({ trackingRefs={trackingRefs!} openCloseWalletPopupRef={openCloseWalletPopupRef} /> + = ({ /> ) : ( - <> + <> + { + openCloseProfile(false) + }} + connectWallet={connectWallet!} + disconnectWallet={disconnectWallet} + nearNetwork={nearNetwork} + trackingRefs={trackingRefs!} + openCloseWalletPopupRef={openCloseWalletPopupRef} + /> + + + + + Sign in + + + + + + + To see personalized notifications, you must sign in by connecting your wallet. + + + )} } diff --git a/libs/shared-components/src/mini-overlay/profile.tsx b/libs/shared-components/src/mini-overlay/profile.tsx index 12cc1656..e7192fcc 100644 --- a/libs/shared-components/src/mini-overlay/profile.tsx +++ b/libs/shared-components/src/mini-overlay/profile.tsx @@ -19,10 +19,10 @@ const ProfileWrapper = styled.div` border-radius: 10px; padding: 4px 10px; background: #fff; + font-family: sans-serif; box-shadow: 0px 4px 20px 0px #0b576f26, 0px 4px 5px 0px #2d343c1a; - font-family: sans-serif; ` const ButtonConnectWrapper = styled.button` diff --git a/libs/shared-components/src/mini-overlay/side-panel.tsx b/libs/shared-components/src/mini-overlay/side-panel.tsx index d22e8196..8200a7ea 100644 --- a/libs/shared-components/src/mini-overlay/side-panel.tsx +++ b/libs/shared-components/src/mini-overlay/side-panel.tsx @@ -4,9 +4,15 @@ import { Button } from 'antd' import { useNotifications } from '@mweb/engine' import { AppWithSettings, EntitySourceType, MutationDto } from '@mweb/backend' import { Image } from '../common/image' - import { IWalletConnect } from './types' -import { MutationFallbackIcon, ArrowIcon, OpenOverlay, OpenOverlayWithCircle } from './assets/icons' +import { + MutationFallbackIcon, + ArrowIcon, + OpenOverlay, + OpenOverlayWithCircle, + BellWithCircle, + BellIcon, +} from './assets/icons' import { Badge } from '../common/Badge' const SidePanelWrapper = styled.div<{ $isApps: boolean }>` @@ -223,7 +229,6 @@ const SidePanel: React.FC = ({ nearNetwork, connectWallet, disconnectWallet, - loggedInAccountId, baseMutation, mutationApps, overlayRef, @@ -281,13 +286,12 @@ const SidePanel: React.FC = ({ )}
- openCloseNotificationPage((val) => !val)} > - {haveUnreadNotifications ? : } + {haveUnreadNotifications ? : } diff --git a/libs/shared-components/src/multitable-panel/assets/images/default.tsx b/libs/shared-components/src/multitable-panel/assets/images/default.tsx new file mode 100644 index 00000000..bad478af --- /dev/null +++ b/libs/shared-components/src/multitable-panel/assets/images/default.tsx @@ -0,0 +1,42 @@ +import React from 'react' + +const Default = () => ( + + + + + + + + + + + + + + +) + +export default Default diff --git a/libs/shared-components/src/multitable-panel/assets/styles-dropdown.tsx b/libs/shared-components/src/multitable-panel/assets/styles-dropdown.tsx new file mode 100644 index 00000000..c7de558b --- /dev/null +++ b/libs/shared-components/src/multitable-panel/assets/styles-dropdown.tsx @@ -0,0 +1,296 @@ +import styled from 'styled-components' + +export const WrapperDropdown = styled.div` + position: relative; + display: flex; + align-items: center; + width: calc(100% - 2px); + border-radius: 4px; + border: 1px solid #e2e2e5; + box-sizing: border-box; + margin: 8px 0; + + &::-webkit-scrollbar { + width: 0; + } +` + +export const MutationsList = styled.div` + outline: none; + display: flex; + flex-direction: column; + width: 100%; + padding: 0; + border-radius: 0px 0px 10px 10px; + opacity: 1; + + &::-webkit-scrollbar { + width: 0; + } +` + +export const MutationsListWrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding: 6px; + gap: 10px; + position: relative; + + &::-webkit-scrollbar { + width: 0; + } +` +export const ButtonListBlock = styled.div` + display: flex; + border-radius: 0px, 0px, 10px, 10px; + height: 40px; + justify-content: space-evenly; + width: 100%; + align-items: center; + top: 42px; + left: 0; + background: #f8f9ff; +` + +export const ButtonBack = styled.div` + display: flex; + background: #f8f9ff; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 400; + line-height: 20.86px; + color: #7a818b; + cursor: pointer; + z-index: 1; + width: 40%; + height: 70%; + padding-bottom: 10px; + padding-top: 10px; + transition: all 0.2s ease; + svg { + margin-right: 5px; + } + &:hover { + background: rgba(56, 75, 255, 0.1); + border-radius: 4px; + } +` + +export const ButtonMutation = styled.div` + display: flex; + background: #f8f9ff; + align-items: center; + justify-content: center; + color: #384bff; + font-size: 14px; + font-weight: 400; + line-height: 20.86px; + cursor: pointer; + z-index: 1; + width: 40%; + height: 70%; + padding-bottom: 10px; + padding-top: 10px; + transition: all 0.2s ease; + svg { + margin-left: 5px; + } + &:hover { + background: rgba(56, 75, 255, 0.1); + border-radius: 4px; + } +` + +export const ListMutations = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 3px; + z-index: 1; +` + +export const InputBlock = styled.div<{ isActive?: boolean }>` + display: flex; + + padding: 2px 4px; + cursor: pointer; + + align-items: center; + width: 100%; + + color: ${(props) => (props.isActive ? '#384BFF' : '#7A818B')}; + border-radius: 4px; + + .inputMutation { + color: ${(props) => (props.isActive ? '#384BFF' : '#7A818B')}; + } + + &:hover { + background: #384bff; + + div, + span { + color: #fff; + } + + svg { + fill: #fff; + + path { + stroke: #fff; + } + } + } +` +export const InputIconWrapper = styled.div` + display: flex; + padding-right: 3px; + transition: all 0.15s ease; + color: #a0a2a7; + justify-content: center; + padding: 0; + width: 30px; + + & svg { + vertical-align: initial; + } + + &:hover { + color: #656669; + + svg { + transform: scale(1.2); + } + } + + &:active { + color: #4f5053; + } +` + +export const InputInfoWrapper = styled.div` + display: flex; + + padding: 4px; + padding-left: 6px; + cursor: pointer; + + position: relative; + + flex-direction: column; + align-items: flex-start; + flex: 1; + + .inputMutationSelected { + color: rgba(34, 34, 34, 1); + } + + .authorMutationSelected { + color: #384bff; + } +` +export const ImageBlock = styled.div` + width: 30px; + border-radius: 4px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +` + +export const AvalibleMutations = styled.div` + width: 100%; + background: #f8f9ff; + border-radius: 10px; + gap: 10px; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: 10px; + z-index: 1; + + .avalibleMutationsInput { + background: rgba(248, 249, 255, 1); + width: 100%; + border-radius: 4px; + padding: 2px 4px; + margin-bottom: 3px; + + &:hover { + background: #384bff; + } + } +` + +export const AvalibleLableBlock = styled.div` + display: flex; + cursor: pointer; + align-items: center; + justify-content: space-between; + width: 100%; + .iconRotate { + svg { + transform: rotate(0deg); + } + } +` + +export const AvalibleLable = styled.span` + font-size: 8px; + font-weight: 700; + line-height: 8px; + text-transform: uppercase; + color: #7a818b; +` + +export const AvalibleArrowBlock = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + transition: all 0.2s ease; + cursor: pointer; + svg { + margin-left: 10px; + transform: rotate(180deg); + } +` + +export const AvalibleArrowLable = styled.span` + font-size: 8px; + font-weight: 700; + line-height: 11.92px; + color: #7a818b; +` + +export const InputMutation = styled.span` + font-size: 12px; + line-height: 149%; + + color: rgba(34, 34, 34, 0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 180px; + display: inline-flex; + align-items: center; +` + +export const AuthorMutation = styled.div` + font-size: 10px; + line-height: 100%; + color: rgba(34, 34, 34, 0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 180px; + display: inline-flex; + align-items: center; +` diff --git a/libs/shared-components/src/multitable-panel/assets/vectors.tsx b/libs/shared-components/src/multitable-panel/assets/vectors.tsx new file mode 100644 index 00000000..4f20469f --- /dev/null +++ b/libs/shared-components/src/multitable-panel/assets/vectors.tsx @@ -0,0 +1,153 @@ +import React from 'react' + +export const Back = () => ( + + + + + + + + + + + +) + +export const Trash = () => ( + + + + + + +) + +export const Mutate = () => ( + + + +) + +export const StarMutationListDefault = () => ( + + + +) + +export const StarMutationList = () => ( + + + +) + +export const AvailableIcon = () => ( + + + +) + +export const PlusCircle = () => ( + + + + + + + +) + +export const MinusCircle = () => ( + + + + + + +) diff --git a/libs/shared-components/src/multitable-panel/components/alert.tsx b/libs/shared-components/src/multitable-panel/components/alert.tsx new file mode 100644 index 00000000..3ecec77a --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/alert.tsx @@ -0,0 +1,181 @@ +import React from 'react' +import styled from 'styled-components' + +const WrapperAlert = styled.div<{ severity?: 'success' | 'info' | 'warning' | 'error' }>` + display: flex; + padding: 4px 6px; + gap: 6px; + border-radius: 5px; + align-items: center; + justify-content: center; + background: ${(p) => + p.severity === 'success' + ? 'rgba(233, 252, 240, 1)' + : p.severity === 'warning' + ? 'rgba(255, 248, 235, 1)' + : p.severity === 'error' + ? 'rgba(246, 240, 246, 1)' + : 'rgba(234, 241, 255, 1)'}; + + color: ${(p) => + p.severity === 'success' + ? 'rgba(3, 187, 66, 1)' + : p.severity === 'warning' + ? 'rgba(208, 145, 26, 1)' + : p.severity === 'error' + ? 'rgba(219, 80, 74, 1)' + : 'rgba(36, 110, 253, 1)'}; + + outline: none; +` + +const TextAlert = styled.span` + font-size: 12px; + font-weight: 400; + line-height: 17.88px; + text-align: left; +` + +const IconAlert = styled.span`` + +const SuccessIcon = () => ( + + + + + + + + + + + +) + +const InfoIcon = () => ( + + + + + + + + + + + + +) + +const WarningIcon = () => ( + + + + + +) + +const ErrorIcon = () => ( + + + + + + + + + + + + +) + +export interface AlertProps { + severity: 'success' | 'info' | 'warning' | 'error' + text: string +} + +export const Alert: React.FC = ({ severity, text }) => { + return ( + + + {severity === 'success' ? ( + + ) : severity === 'warning' ? ( + + ) : severity === 'error' ? ( + + ) : ( + + )} + + {text} + + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/application-card.tsx b/libs/shared-components/src/multitable-panel/components/application-card.tsx new file mode 100644 index 00000000..3dac2db3 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/application-card.tsx @@ -0,0 +1,320 @@ +import { useAppDocuments } from '@mweb/engine' +import { ApplicationDto, DocumentDto, EntitySourceType } from '@mweb/backend' +import React from 'react' +import styled from 'styled-components' +import { Image } from './image' +import { DocumentCard } from './document-card' +import { AppInMutation } from '@mweb/backend' +import { Spin } from 'antd' +import { Badge } from './badge' + +const Card = styled.div<{ $backgroundColor?: string }>` + position: relative; + width: 100%; + border-radius: 10px; + background: ${(p) => p.$backgroundColor}; + border: 1px solid #eceef0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + &:hover { + background: rgba(24, 121, 206, 0.1); + } + &.disabled { + opacity: 0.7; + } + &.disabled:hover { + background: #fff; + } +` + +const CardBody = styled.div` + padding: 10px 6px; + display: flex; + gap: 6px; + align-items: center; + + > * { + min-width: 0; + } +` + +const CardContent = styled.div` + width: 100%; +` + +type TTextLink = { + bold?: boolean + small?: boolean + ellipsis?: boolean + $color?: string +} + +const TextLink = styled.div` + display: block; + margin: 0; + font-size: 14px; + line-height: 18px; + color: ${(p) => + p.$color ? `${p.$color} !important` : p.bold ? '#11181C !important' : '#687076 !important'}; + font-weight: ${(p) => (p.bold ? '600' : '400')}; + font-size: ${(p) => (p.small ? '12px' : '14px')}; + overflow: ${(p) => (p.ellipsis ? 'hidden' : 'visible')}; + text-overflow: ${(p) => (p.ellipsis ? 'ellipsis' : 'unset')}; + white-space: nowrap; + outline: none; +` + +const Thumbnail = styled.div<{ $shape: 'circle' | 'default' }>` + display: block; + width: 60px; + height: 60px; + flex-shrink: 0; + border: 1px solid #eceef0; + border-radius: ${(props) => (props.$shape === 'circle' ? '99em' : '8px')}; + overflow: hidden; + outline: none; + transition: border-color 200ms; + + &:focus, + &:hover { + border-color: #d0d5dd; + } + + img { + object-fit: cover; + width: 100%; + height: 100%; + } +` + +const ButtonLink = styled.button` + padding: 8px; + cursor: pointer; + text-decoration: none; + outline: none; + border: none; + background: inherit; + &:hover, + &:focus { + text-decoration: none; + outline: none; + border: none; + background: inherit; + } + &.disabled { + cursor: default; + } +` + +const DocumentsWrapper = styled.div` + display: flex; + padding-bottom: 10px; +` + +const SideLine = styled.div` + border: 1px solid #c1c6ce; + margin: 0 10px; +` + +const DocumentCardList = styled.div` + width: 100%; + margin-right: 10px; + display: flex; + flex-direction: column; + gap: 6px; +` + +const MoreIcon = () => ( + + + + +) + +const UncheckedIcon = () => ( + + + + + +) + +const CheckedIcon = () => ( + + + +) + +export interface ISimpleApplicationCardProps { + src: string + metadata: ApplicationDto['metadata'] + source: ApplicationDto['source'] + disabled: boolean + isChecked: boolean + onChange: (isChecked: boolean) => void + iconShape?: 'circle' + textColor?: string + backgroundColor?: string +} + +export interface IApplicationCardWithDocsProps { + src: string + metadata: ApplicationDto['metadata'] + source: ApplicationDto['source'] + disabled: boolean + docsIds: AppInMutation['documentId'][] + onDocCheckboxChange: (docId: string | null, isChecked: boolean) => void + onOpenDocumentsModal: (docs: DocumentDto[]) => void +} + +interface IApplicationCard + extends ISimpleApplicationCardProps, + Omit { + hasDocuments: boolean + usingDocs: (DocumentDto | null)[] + allDocs: DocumentDto[] +} + +const ApplicationCard: React.FC = ({ + src, + metadata, + disabled, + hasDocuments, + iconShape, + textColor, + backgroundColor, + isChecked, + usingDocs, + allDocs, + source, + onChange, + onDocCheckboxChange, + onOpenDocumentsModal, +}) => { + const [accountId, , appId] = src.split('/') + return ( + + + + {metadata.name} + + + + + {metadata.name || appId} + + + + {source === EntitySourceType.Local && ( + + )}{' '} + {accountId ? `@${accountId}` : null} + + + + onOpenDocumentsModal(allDocs) : () => onChange(!isChecked)} + > + {hasDocuments ? : isChecked ? : } + + + + {hasDocuments && usingDocs.length ? ( + + + + {usingDocs.map((doc) => ( + onDocCheckboxChange(doc?.id ?? null, false)} + disabled={disabled} + appMetadata={metadata} + source={doc?.source} + /> + ))} + + + ) : null} + + ) +} + +export const SimpleApplicationCard: React.FC = (props) => ( + null} + onDocCheckboxChange={() => null} + usingDocs={[]} + allDocs={[]} + /> +) + +export const ApplicationCardWithDocs: React.FC = (props) => { + const { src, docsIds } = props + const { documents, isLoading } = useAppDocuments(src) + const usingDocs: (DocumentDto | null)[] = documents?.filter((doc) => docsIds.includes(doc.id)) + if (docsIds.includes(null)) usingDocs.unshift(null) + + return isLoading ? ( + + + + + + ) : ( + null} + usingDocs={usingDocs} + allDocs={documents} + /> + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/badge.tsx b/libs/shared-components/src/multitable-panel/components/badge.tsx new file mode 100644 index 00000000..81a4258e --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/badge.tsx @@ -0,0 +1,77 @@ +import React, { FC } from 'react' +import styled from 'styled-components' + +const SizeMap = { + tiny: { + gap: '2px', + height: undefined, + padding: '2px', + iconSize: undefined, + }, + small: { + gap: '3px', + height: '18px', + padding: '3px', + iconSize: '12px', + }, +} + +const Wrapper = styled.span<{ + $theme: 'yellow' | 'blue' | 'white' + $margin: string + $isClickable: boolean + $size: keyof typeof SizeMap +}>` + display: inline-flex; + text-transform: uppercase; + padding: ${(props) => SizeMap[props.$size].padding}; + border-radius: 4px; + line-height: 1; + background: ${(props) => + props.$theme === 'yellow' ? '#FAC20A' : props.$theme === 'blue' ? '#384BFF' : '#fff'}; + color: ${(props) => (props.$theme === 'yellow' || props.$theme === 'blue' ? '#fff' : '#384BFF')}; + font-size: 8px; + font-weight: 600; + margin: ${(props) => props.$margin}; + align-items: center; + justify-content: center; + gap: ${(props) => SizeMap[props.$size].gap}; + height: ${(props) => SizeMap[props.$size].height}; + + ${(props) => + props.$isClickable + ? ` + &:hover:not(:disabled) { + opacity: 0.75; + } + + &:active:not(:disabled) { + opacity: 0.5; + } + ` + : ''} +` + +export interface IBadgeProps { + text: string + theme: 'yellow' | 'blue' | 'white' + margin: string + size?: keyof typeof SizeMap + icon?: React.JSX.Element + onClick?: React.MouseEventHandler +} + +export const Badge: FC = ({ text, theme, margin, icon, onClick, size = 'tiny' }) => { + return ( + + {icon ? {icon} : null} + {text} + + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/button.tsx b/libs/shared-components/src/multitable-panel/components/button.tsx new file mode 100644 index 00000000..66342815 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/button.tsx @@ -0,0 +1,31 @@ +import styled from 'styled-components' + +export const Button = styled.button<{ primary?: boolean; danger?: boolean }>` + display: flex; + justify-content: center; + align-items: center; + border: ${(p) => (p.primary || p.danger ? 'none' : '1px solid rgba(226, 226, 229, 1)')}; + color: ${(p) => (p.primary || p.danger ? '#fff' : 'rgba(2, 25, 58, 1)')}; + background: ${(p) => + p.primary ? 'rgba(56, 75, 255, 1)' : p.danger ? 'rgba(219, 80, 74, 1)' : 'inherit'}; + height: 42px; + border-radius: 10px; + font-size: 14px; + font-weight: 400; + line-height: 20.86px; + text-align: center; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: auto; + } + + &:hover:not(:disabled) { + opacity: 0.75; + } + + &:active:not(:disabled) { + opacity: 0.5; + } +` diff --git a/libs/shared-components/src/multitable-panel/components/buttons-group.tsx b/libs/shared-components/src/multitable-panel/components/buttons-group.tsx new file mode 100644 index 00000000..05932b07 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/buttons-group.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components' + +export const ButtonsGroup = styled.div` + display: flex; + align-items: center; + gap: 10px; + + > * { + flex: 1; + } +` diff --git a/libs/shared-components/src/multitable-panel/components/document-card.tsx b/libs/shared-components/src/multitable-panel/components/document-card.tsx new file mode 100644 index 00000000..9c8f934a --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/document-card.tsx @@ -0,0 +1,191 @@ +import { ApplicationDto, DocumentMetadata, EntitySourceType } from '@mweb/backend' +import React from 'react' +import styled from 'styled-components' +import { Image } from './image' +import { Badge } from './badge' + +const Card = styled.div` + position: relative; + width: 100%; + border-radius: 10px; + background: #f8f9ff; + border: 1px solid #eceef0; + font-family: sans-serif; + &:hover { + background: rgba(24, 121, 206, 0.1); + } + &.disabled { + opacity: 0.7; + } + &.disabled:hover { + background: #f8f9ff; + } +` + +const CardBody = styled.div` + padding: 10px 6px; + display: flex; + gap: 6px; + align-items: center; + + > * { + min-width: 0; + } +` + +const ThumbnailGroup = styled.div` + position: relative; + width: 32px; + height: 32px; + flex-shrink: 0; +` + +const Thumbnail = styled.div` + border: 1px solid #eceef0; + border-radius: 99em; + overflow: hidden; + outline: none; + transition: border-color 200ms; + + &:focus, + &:hover { + border-color: #d0d5dd; + } + + img { + object-fit: cover; + width: 100%; + height: 100%; + } +` + +const ThumbnailMini = styled.div` + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + border: 1px solid #eceef0; + border-radius: 4px; + overflow: hidden; + outline: none; + transition: border-color 200ms; + position: absolute; + bottom: 0; + right: 0; + + &:focus, + &:hover { + border-color: #d0d5dd; + } + + img { + object-fit: cover; + vertical-align: unset; + width: 100%; + height: 100%; + } +` + +const CardContent = styled.div` + width: 100%; +` + +const TextLink = styled.div<{ bold?: boolean; small?: boolean; ellipsis?: boolean }>` + display: block; + margin: 0; + font-size: 14px; + line-height: 18px; + color: ${(p) => (p.bold ? '#11181C !important' : '#687076 !important')}; + font-weight: ${(p) => (p.bold ? '600' : '400')}; + font-size: ${(p) => (p.small ? '12px' : '14px')}; + overflow: ${(p) => (p.ellipsis ? 'hidden' : 'visible')}; + text-overflow: ${(p) => (p.ellipsis ? 'ellipsis' : 'unset')}; + white-space: nowrap; + outline: none; +` + +const ButtonLink = styled.button` + padding: 8px; + cursor: pointer; + text-decoration: none; + outline: none; + border: none; + background: inherit; + &:hover, + &:focus { + text-decoration: none; + outline: none; + border: none; + background: inherit; + } + &.disabled { + cursor: default; + } +` + +const CheckedIcon = () => ( + + + +) + +const FALLBACK_IMAGE_URL = + 'https://ipfs.near.social/ipfs/bafkreifc4burlk35hxom3klq4mysmslfirj7slueenbj7ddwg7pc6ixomu' + +export interface Props { + src: string | null + metadata: DocumentMetadata | null + source?: EntitySourceType + onChange: () => void + disabled: boolean + appMetadata: ApplicationDto['metadata'] +} + +export const DocumentCard: React.FC = ({ + src, + source, + metadata, + onChange, + disabled, + appMetadata, +}) => { + const srcParts = src?.split('/') + + return ( + + + + + {metadata?.name} + + + {appMetadata.name} + + + + + + {metadata?.name || (srcParts && srcParts[2]) || 'New Document'} + + + + {source === EntitySourceType.Local && ( + + )}{' '} + {srcParts?.[0] && `@${srcParts[0]}`} + + + + + + + + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/documents-modal.tsx b/libs/shared-components/src/multitable-panel/components/documents-modal.tsx new file mode 100644 index 00000000..05c96a56 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/documents-modal.tsx @@ -0,0 +1,239 @@ +import { DocumentDto } from '@mweb/backend' +import React, { FC, useState } from 'react' +import styled from 'styled-components' +import { SimpleApplicationCard } from './application-card' +import { Button } from './button' +import { MinusCircle, PlusCircle } from '../assets/vectors' +import { ButtonsGroup } from './buttons-group' + +const Wrapper = styled.div` + position: absolute; + z-index: 3; + top: calc(50% - 10px); + transform: translateY(-50%); + left: 0; + width: calc(100% - 20px); + max-height: calc(100% - 20px); + margin: 10px; + padding: 10px; + border: 1px solid #000; + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 10px; + font-family: sans-serif; + background: #f8f9ff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; +` + +const Close = styled.span` + cursor: pointer; + svg { + margin: 0; + width: 23px; + height: 23px; + + path { + stroke: #838891; + } + } + &:hover { + opacity: 0.5; + } +` + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + color: rgba(2, 25, 58, 1); + font-size: 14px; + font-weight: 600; + line-height: 21.09px; + text-align: left; + gap: 20px; + + .edit { + margin-right: auto; + margin-bottom: 2px; + } +` + +const Title = styled.div` + color: #02193a; +` + +const AppsList = styled.div` + overflow: hidden; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + background: white; + border-radius: 10px; + overscroll-behavior: contain; + + &::-webkit-scrollbar { + cursor: pointer; + width: 4px; + } + + &::-webkit-scrollbar-track { + background: rgb(244 244 244); + background: linear-gradient( + 90deg, + rgb(244 244 244 / 0%) 10%, + rgb(227 227 227 / 100%) 50%, + rgb(244 244 244 / 0%) 90% + ); + } + + &::-webkit-scrollbar-thumb { + width: 4px; + height: 2px; + background: #384bff; + border-radius: 2px; + box-shadow: 0 2px 6px rgb(0 0 0 / 9%), 0 2px 2px rgb(38 117 209 / 4%); + } +` + +const InlineButton = styled.button` + align-self: center; + width: fit-content; + border: none; + display: flex; + gap: 5px; + background: none; + color: #384bff; + font-size: 12px; + font-weight: 400; + line-height: 150%; + text-decoration: none; + cursor: pointer; + &:hover { + opacity: 0.5; + } +` + +const CloseIcon = () => ( + + + + +) + +export interface Props { + docs: DocumentDto[] | null + chosenDocumentsIds: (string | null)[] + setDocumentsIds: (ids: (string | null)[]) => void + onClose: () => void +} + +export const DocumentsModal: FC = ({ + docs, + chosenDocumentsIds, + setDocumentsIds, + onClose, +}) => { + const [chosenDocsIds, setChosenDocsIds] = useState<(string | null)[]>(chosenDocumentsIds) + + const handleDocCheckboxChange = (id: string | null) => + setChosenDocsIds((val) => + chosenDocsIds.includes(id) ? val.filter((docId) => docId !== id) : [...val, id] + ) + + return ( + +
+ Select document + + + +
+ + handleDocCheckboxChange(null)}> + {chosenDocsIds.includes(null) ? ( + <> + + Delete document builder + + ) : ( + <> + + Create from scratch + + )} + + + + {docs?.map((doc) => ( + handleDocCheckboxChange(doc.id)} + disabled={false} + iconShape="circle" + textColor="#4E5E76" + backgroundColor="#F8F9FF" + /> + ))} + + + + + + +
+ ) +} + +const hasArrayTheSameData = (a: (string | null)[], b: (string | null)[]) => { + if (a.length !== b.length) { + return false + } + + const aMap = new Map() + const bMap = new Map() + + for (const item of a) { + aMap.set(item, (aMap.get(item) ?? 0) + 1 || 1) + } + + for (const item of b) { + bMap.set(item, (bMap.get(item) ?? 0) + 1 || 1) + } + + for (const [key, value] of aMap) { + if (bMap.get(key) !== value) { + return false + } + } + + return true +} diff --git a/libs/shared-components/src/multitable-panel/components/dropdown-button.tsx b/libs/shared-components/src/multitable-panel/components/dropdown-button.tsx new file mode 100644 index 00000000..2d87ae76 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/dropdown-button.tsx @@ -0,0 +1,204 @@ +import React, { FC, useMemo, useState } from 'react' +import styled from 'styled-components' + +const DropdownWrapper = styled.div` + position: relative; +` + +const LeftButton = styled.button` + flex: 1; + display: flex; + justify-content: center; + align-items: center; + border: none; + background: rgba(56, 75, 255, 1); + color: #7a818b; + font-size: 14px; + font-weight: 400; + line-height: 20.86px; + text-align: center; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: auto; + } + + &:hover:not(:disabled) { + opacity: 0.75; + } + + &:active:not(:disabled) { + opacity: 0.5; + } +` + +const TextSave = styled.div` + display: inline-block; + overflow: hidden; + word-wrap: no-wrap; + text-overflow: ellipsis; + width: 100%; + padding: 0 10px; + text-align: center; +` + +const RightButton = styled.button<{ isOpened: boolean }>` + display: flex; + justify-content: center; + align-items: center; + width: 42px; + height: 42px; + border: none; + border-left: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(56, 75, 255, 1); + cursor: pointer; + transform: ${(props) => (props.isOpened ? 'rotate(180deg)' : 'rotate(0deg)')}; + + &:disabled { + opacity: 0.5; + cursor: auto; + } + + &:hover:not(:disabled) { + opacity: 0.75; + } + + &:active:not(:disabled) { + opacity: 0.5; + } +` + +const ItemGroup = styled.div` + position: absolute; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + right: 0; + top: 52px; + width: 100%; + padding: 10px; + gap: 5px; + border-radius: 10px; + background: rgba(231, 236, 239, 1); + font-size: 14px; + font-weight: 400; + text-align: center; + color: rgba(34, 34, 34, 1); +` + +const ArrowIcon = () => ( + + + +) + +const DropdownButtonItem = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 31px; + border-radius: 4px; + cursor: pointer; + &:hover { + background: rgba(217, 222, 225, 1); + color: rgba(56, 75, 255, 1); + } +` + +const ButtonGroup = styled.div` + display: flex; + flex-direction: row; + height: 42px; + border-radius: 10px; + overflow: hidden; +` + +type ItemProps = { + value: string + title: string + visible?: boolean + onClick?: (value: string) => void +} + +export interface Props { + value: string + items: ItemProps[] + onClick: (itemId: string) => void + onChange: (itemId: string) => void + disabled: boolean + disabledAll: boolean +} + +export const DropdownButton: FC = ({ + value, + items, + disabled, + disabledAll, + onClick, + onChange, +}) => { + const [isOpened, setIsOpened] = useState(false) + + const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]) + + const currentItem = useMemo( + () => visibleItems.find((item) => item.value === value), + [visibleItems, value] + ) + + if (!currentItem) { + throw new Error( + `[DropdownButton] Invalid value: ${value}. Possible values: ${visibleItems + .map((item) => item.value) + .join(', ')}` + ) + } + + const handleDropdownToggle = () => { + setIsOpened((val) => !val) + } + + const handleButtonItemClick = (item: ItemProps) => { + onChange(item.value) + handleDropdownToggle() + } + + const handleMainButtonClick = () => { + onClick(currentItem.value) + currentItem.onClick?.(currentItem.value) + } + + return ( + + + + {currentItem.title} + + {visibleItems.length > 1 ? ( + + + + ) : null} + + + {isOpened ? ( + + {visibleItems.map((item) => ( + handleButtonItemClick(item)}> + {item.title} + + ))} + + ) : null} + + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/dropdown.tsx b/libs/shared-components/src/multitable-panel/components/dropdown.tsx new file mode 100644 index 00000000..31e744f0 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/dropdown.tsx @@ -0,0 +1,295 @@ +import React, { DetailedHTMLProps, FC, HTMLAttributes, useMemo, useState } from 'react' +import { + AuthorMutation, + AvalibleArrowBlock, + AvalibleArrowLable, + AvalibleLable, + AvalibleLableBlock, + AvalibleMutations, + ButtonBack, + ButtonListBlock, + ButtonMutation, + ImageBlock, + InputBlock, + InputIconWrapper, + InputInfoWrapper, + InputMutation, + ListMutations, + MutationsList, + MutationsListWrapper, + WrapperDropdown, +} from '../assets/styles-dropdown' +import { + AvailableIcon, + Back, + Mutate, + StarMutationList, + StarMutationListDefault, +} from '../assets/vectors' +import { useDeleteLocalMutation, useMutableWeb } from '@mweb/engine' +import { EntitySourceType, MutationWithSettings } from '@mweb/backend' +import { Image } from './image' +import { Badge } from './badge' +import { ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons' +import styled from 'styled-components' +import { ModalDelete } from './modal-delete' + +const ModalConfirmBackground = styled.div` + position: absolute; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + min-height: 190px; + top: 0; + left: 0; + background-color: rgba(255, 255, 255, 0.7); + border-radius: inherit; + z-index: 1; +` + +export type DropdownProps = DetailedHTMLProps, HTMLDivElement> & { + onMutateButtonClick: () => void +} + +export const Dropdown: FC = ({ onMutateButtonClick }: DropdownProps) => { + const { + mutations, + selectedMutation, + favoriteMutationId, + setFavoriteMutation, + switchMutation, + getPreferredSource, + removeMutationFromRecents, + } = useMutableWeb() + + const { deleteLocalMutation } = useDeleteLocalMutation() + + const recentlyUsedMutations = useMemo( + () => + Object.groupBy( + mutations + .filter((mut) => mut.settings.lastUsage) + .sort((a, b) => { + const dateA = a.settings.lastUsage ? new Date(a.settings.lastUsage).getTime() : null + const dateB = b.settings.lastUsage ? new Date(b.settings.lastUsage).getTime() : null + + if (!dateA) return 1 + if (!dateB) return -1 + + return dateB - dateB + }), + (mut) => mut.id + ), + [mutations] + ) + + const [isAccordeonExpanded, setIsAccordeonExpanded] = useState( + Object.keys(recentlyUsedMutations).length === 0 + ) + const [mutationIdToDelete, setMutationIdToDelete] = useState(null) + + const unusedMutations = useMemo( + () => + Object.groupBy( + mutations.filter((mut) => !mut.settings.lastUsage), + (mut) => mut.id + ), + [mutations] + ) + + const handleMutationClick = (mutationId: string) => { + switchMutation(mutationId) + } + + // todo: mock + const handleAccordeonClick = () => { + setIsAccordeonExpanded((val) => !val) + } + + const handleMutateButtonClick = () => { + onMutateButtonClick() + } + + const handleFavoriteButtonClick = (mutationId: string) => { + setFavoriteMutation(mutationId === favoriteMutationId ? null : mutationId) + } + + const handleOriginalButtonClick = async () => { + switchMutation(null) + } + + const handleRemoveFromRecentlyUsedClick = async (mut: MutationWithSettings) => { + removeMutationFromRecents(mut.id) + } + + return ( + + + + {} to Original + + Mutate {} +
+
+
+ + {Object.keys(recentlyUsedMutations).length > 0 ? ( + + {Object.values(recentlyUsedMutations).map((muts) => { + if (!muts || !muts.length) return null + const recentlyUsedSource = getPreferredSource(muts[0].id) + const mut = + muts.find( + (mut) => mut.source === (recentlyUsedSource ?? EntitySourceType.Local) + ) ?? muts[0] + return ( + + + + + handleMutationClick(mut.id)}> + {/* todo: mocked classname */} + + {mut.metadata ? mut.metadata.name : ''}{' '} + {recentlyUsedMutations[mut.id]?.length === 2 ? ( + mut.source === EntitySourceType.Local ? ( + + ) : ( + + ) + ) : mut.source === EntitySourceType.Local ? ( + + ) : null} + + {/* todo: mocked classname */} + {mut.authorId ? ( + + by {mut.authorId} + + ) : null} + + {/* todo: mocked */} + + {mut.id === favoriteMutationId ? ( + handleFavoriteButtonClick(mut.id)}> + + + ) : mut.id === selectedMutation?.id ? ( + handleFavoriteButtonClick(mut.id)}> + + + ) : null} + + {mut.source === EntitySourceType.Local ? ( + setMutationIdToDelete(mut.id)}> + + + ) : mut.id !== selectedMutation?.id && + mut.id !== favoriteMutationId && + !getPreferredSource(mut.id) ? ( + handleRemoveFromRecentlyUsedClick(mut)}> + + + ) : null} + + ) + })} +
+
+ ) : null} + + {Object.keys(unusedMutations).length > 0 ? ( + + + available + {/* todo: mock */} + + + {Object.keys(unusedMutations).length} mutations + + + +
+
+ + {isAccordeonExpanded ? ( +
+ {Object.values(unusedMutations).map((muts) => { + if (!muts) return null + const [mut] = muts + + return ( + handleMutationClick(mut.id)} + className="avalibleMutationsInput" + > + + + + + {mut.metadata ? mut.metadata.name : ''} + {mut.authorId ? by {mut.authorId} : null} + + + ) + })} +
+ ) : null} +
+ ) : null} +
+ + {mutationIdToDelete && ( + + { + await deleteLocalMutation(mutationIdToDelete) + if (mutationIdToDelete === favoriteMutationId) + handleFavoriteButtonClick(mutationIdToDelete) + setMutationIdToDelete(null) + }} + onCloseCurrent={() => setMutationIdToDelete(null)} + /> + + )} +
+
+ ) +} diff --git a/libs/shared-components/src/multitable-panel/components/image.tsx b/libs/shared-components/src/multitable-panel/components/image.tsx new file mode 100644 index 00000000..929b4aba --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/image.tsx @@ -0,0 +1,36 @@ +import React, { FC, useEffect, useState } from 'react' + +export interface Props { + image?: { + ipfs_cid?: string + url?: string + } + + fallbackUrl?: string + alt?: string +} + +export const Image: FC = ({ image, alt, fallbackUrl }) => { + const [imageUrl, setImageUrl] = useState(undefined) + + // todo: image can changed. need watch + useEffect(() => { + image?.ipfs_cid + ? setImageUrl(`https://ipfs.near.social/ipfs/${image.ipfs_cid}`) + : image?.url + ? setImageUrl(image?.url) + : setImageUrl(fallbackUrl) + }, [image]) + + return ( + {alt} { + if (imageUrl !== fallbackUrl) { + setImageUrl(fallbackUrl) + } + }} + /> + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/input.tsx b/libs/shared-components/src/multitable-panel/components/input.tsx new file mode 100644 index 00000000..cb8366a3 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/input.tsx @@ -0,0 +1,56 @@ +import React, { FC, useId } from 'react' +import FloatingLabel from 'react-bootstrap/FloatingLabel' +import Form from 'react-bootstrap/Form' +import styled from 'styled-components' +const InputContainer = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + + label { + font-size: 14px; + } + .form-floating > .form-control { + height: 48px; + min-height: 48px; + } + input { + flex: 1; + padding: 10px 10px; + border-radius: 10px; + border: 1px solid #e2e2e5; + font-size: 14px; + + &:focus { + border: 1px solid rgba(56, 75, 255, 1); + outline: none; + } + } +` + +interface Props { + label: string + value: string + placeholder: string + disabled?: boolean + onChange?: (value: string) => void + readonly?: boolean +} + +export const Input: FC = ({ value, label, placeholder, disabled, onChange, readonly }) => { + const inputId = useId() + return ( + + + onChange && onChange(e.target.value)} + value={value} + disabled={disabled} + type="text" + placeholder={placeholder} + readOnly={readonly} + /> + + + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/modal-delete.tsx b/libs/shared-components/src/multitable-panel/components/modal-delete.tsx new file mode 100644 index 00000000..5214f138 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/modal-delete.tsx @@ -0,0 +1,69 @@ +import React, { FC } from 'react' +import styled from 'styled-components' +import { useEscape } from '../../hooks/use-escape' +import { Button } from './button' +import { ButtonsGroup } from './buttons-group' + +const ModalConfirmWrapper = styled.div` + display: flex; + flex-direction: column; + width: 300px; + padding: 20px; + gap: 10px; + border-radius: 10px; + z-index: 5; + background: #fff; + box-shadow: + 0px 5px 11px 0px rgba(2, 25, 58, 0.1), + 0px 19px 19px 0px rgba(2, 25, 58, 0.09), + 0px 43px 26px 0px rgba(2, 25, 58, 0.05), + 0px 77px 31px 0px rgba(2, 25, 58, 0.01), + 0px 120px 34px 0px rgba(2, 25, 58, 0); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + + input[type='checkbox'] { + accent-color: #384bff; + } +` + +const Title = styled.h1` + margin: 0; + text-align: center; + color: #02193a; + font-family: inherit; + font-size: 22px; + font-weight: 600; + line-height: 150%; +` + +const Message = styled.p` + margin: 0; + text-align: center; + color: #02193a; + font-family: inherit; + font-size: 14px; + font-weight: 400; + line-height: 150%; +` + +export interface Props { + onAction: () => void + onCloseCurrent: () => void +} + +export const ModalDelete: FC = ({ onAction, onCloseCurrent }) => { + useEscape(onCloseCurrent) + return ( + + Delete local mutation + You're going to delete the local mutation. + + + + + + ) +} diff --git a/libs/shared-components/src/multitable-panel/components/modals-confirm.tsx b/libs/shared-components/src/multitable-panel/components/modals-confirm.tsx new file mode 100644 index 00000000..9ad598e1 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/modals-confirm.tsx @@ -0,0 +1,559 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react' +import styled from 'styled-components' +import { + useCreateMutation, + useEditMutation, + useMutableWeb, + useDeleteLocalMutation, +} from '@mweb/engine' +import { EntitySourceType, MutationCreateDto, MutationDto } from '@mweb/backend' +import { Image } from './image' +import { useEscape } from '../../hooks/use-escape' +import { Alert, AlertProps } from './alert' +import { Button } from './button' +import { InputImage } from './upload-image' +import { cloneDeep } from '../../helpers' +import { DropdownButton } from './dropdown-button' +import { ButtonsGroup } from './buttons-group' + +enum MutationModalMode { + Editing = 'editing', + Creating = 'creating', + Forking = 'forking', +} + +const ModalConfirmWrapper = styled.div` + display: flex; + flex-direction: column; + width: 300px; + max-height: calc(100% - 40px); + padding: 20px; + gap: 10px; + border-radius: 10px; + z-index: 5; + background: #fff; + box-shadow: + 0px 5px 11px 0px rgba(2, 25, 58, 0.1), + 0px 19px 19px 0px rgba(2, 25, 58, 0.09), + 0px 43px 26px 0px rgba(2, 25, 58, 0.05), + 0px 77px 31px 0px rgba(2, 25, 58, 0.01), + 0px 120px 34px 0px rgba(2, 25, 58, 0); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + + input[type='checkbox'] { + accent-color: #384bff; + } +` + +const HeaderTitle = styled.h1` + margin: 0; + text-align: center; + color: #02193a; + font-family: inherit; + font-size: 22px; + font-weight: 600; + line-height: 150%; +` + +const Label = styled.div` + color: #7a818b; + font-size: 8px; + text-transform: uppercase; + font-weight: 700; +` + +const CardWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +` + +const ImgWrapper = styled.div` + width: 42px; + height: 42px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + } +` + +const TextWrapper = styled.div` + display: flex; + flex-direction: column; + width: calc(100% - 52px); + + p { + font-size: 14px; + font-weight: 600; + color: #02193a; + margin: 0; + overflow-wrap: break-word; + } + + span { + font-size: 10px; + color: #7a818b; + overflow-wrap: break-word; + } +` + +const FloatingLabelContainer = styled.div` + background: #f8f9ff; + border-radius: 10px; + overflow: hidden; + box-sizing: border-box; + position: relative; + width: 100%; +` + +const StyledInput = styled.input` + padding: 20px 10px 9px 10px; + background: inherit; + color: #02193a; + line-height: 100%; + font-size: 12px; + border-radius: 10px; + width: 100%; + outline: none; + border: none; +` + +const StyledLabel = styled.label` + top: 0.5rem; + left: 10px; + font-size: 10px; + color: #7a818b; + position: absolute; + user-select: none; + + span { + color: #db504a; + } +` + +const FloatingLabelContainerArea = styled.div` + background: #f8f9ff; + border-radius: 10px; + overflow: hidden; + box-sizing: border-box; + position: relative; + flex: 1 1 auto; + display: flex; + border-radius: 10px; +` + +const StyledTextarea = styled.textarea` + padding: 25px 10px 10px; + background: inherit; + color: #02193a; + line-height: 100%; + font-size: 13px; + border-radius: 10px; + width: 100%; + outline: none; + min-height: 77px; + position: relative; + border: none; +` + +const CheckboxBlock = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + span { + font-size: 12px; + color: #02193a; + } +` + +const CheckboxInput = styled.input` + width: 16px; + height: 16px; + border-radius: 5px; + border: 1px solid #384bff; +` + +export interface Props { + itemType: 'mutation' | 'document' + onCloseCurrent: () => void + onCloseAll: () => void + editingMutation: MutationDto + loggedInAccountId: string +} + +interface IAlert extends AlertProps { + id: string +} + +// ToDo: duplication -- move somewhere +const alerts: { [name: string]: IAlert } = { + noWallet: { + id: 'noWallet', + text: 'Connect the NEAR wallet to create the mutation.', + severity: 'warning', + }, + emptyMutation: { + id: 'emptyMutation', + text: 'A mutation cannot be empty.', + severity: 'warning', + }, + notEditedMutation: { + id: 'notEditedMutation', + text: 'No changes found!', + severity: 'warning', + }, + idIsNotUnique: { + id: 'idIsNotUnique', + text: 'This mutation ID already exists.', + severity: 'warning', + }, + noName: { + id: 'noName', + text: 'Name must be specified.', + severity: 'error', + }, + noImage: { + id: 'noImage', + text: 'Image must be specified.', + severity: 'error', + }, +} + +export const ModalConfirm: FC = ({ + itemType, + onCloseCurrent, + onCloseAll, + editingMutation, + loggedInAccountId, +}) => { + const { name, image, description, fork_of } = editingMutation.metadata + + // Close modal with escape key + useEscape(onCloseCurrent) // ToDo -- does not work + + const [newName, setName] = useState(name ?? '') + const [newImage, setImage] = useState<{ ipfs_cid?: string } | undefined>(image) + const [newDescription, setDescription] = useState(description ?? '') + const [isApplyToOriginChecked, setIsApplyToOriginChecked] = useState(false) // ToDo: separate checkboxes + const [alert, setAlert] = useState(null) + const { mutations, switchMutation, switchPreferredSource } = useMutableWeb() + + const [mode, setMode] = useState( + !editingMutation.authorId // Newly created local mutation doesn't have author + ? MutationModalMode.Creating + : editingMutation.authorId === loggedInAccountId + ? MutationModalMode.Editing + : MutationModalMode.Forking + ) + + const forkedMutation = useMemo(() => { + if (mode !== MutationModalMode.Editing || !fork_of) return null + return mutations.find((mutation) => mutation.id === fork_of) + }, [fork_of, mutations, mode]) + + const { createMutation, isLoading: isCreating } = useCreateMutation() + const { editMutation, isLoading: isEditing } = useEditMutation() + const { deleteLocalMutation } = useDeleteLocalMutation() + + const isFormDisabled = isCreating || isEditing + + useEffect(() => setAlert(null), [newName, newImage, newDescription, isApplyToOriginChecked]) + + // const checkIfModified = useCallback( + // (mutationToPublish: MutationDto) => + // baseMutation ? !compareMutations(baseMutation, mutationToPublish) : true, + // [baseMutation] + // ) + + const doChecksForAlerts = useCallback( + (mutationToPublish: MutationCreateDto | MutationDto, isEditing: boolean): IAlert | null => { + if (!mutationToPublish.metadata.name) return alerts.noName + if (!mutationToPublish.metadata.image) return alerts.noImage + // if ( + // isEditing && + // !isApplyToOriginChecked && + // !checkIfModified(mutationToPublish as MutationDto) + // ) + // return alerts.notEditedMutation + return null + }, + [newName, newImage, isApplyToOriginChecked] // checkIfModified + ) + + const handleSaveClick = async () => { + const mutationToPublish = cloneDeep(editingMutation) + mutationToPublish.metadata.name = newName.trim() + mutationToPublish.metadata.image = newImage + mutationToPublish.metadata.description = newDescription.trim() + mutationToPublish.source = EntitySourceType.Origin // save to the contract + + if (mode === MutationModalMode.Forking) { + mutationToPublish.metadata.fork_of = mutationToPublish.id + } + + const newAlert = doChecksForAlerts(mutationToPublish, mode === MutationModalMode.Editing) + if (newAlert) { + setAlert(newAlert) + return + } + + if (mode === MutationModalMode.Creating || mode === MutationModalMode.Forking) { + try { + const id = await createMutation( + mutationToPublish, + mode === MutationModalMode.Forking + ? { askOriginToApplyChanges: isApplyToOriginChecked } + : undefined + ) + switchMutation(id) + switchPreferredSource(id, EntitySourceType.Origin) + await deleteLocalMutation(mutationToPublish.id) + onCloseAll() + } catch (error: any) { + if (error?.message === 'Mutation with that ID already exists') { + setAlert(alerts.idIsNotUnique) + } + } + } else if (mode === MutationModalMode.Editing) { + try { + await editMutation( + mutationToPublish as MutationDto, + forkedMutation && isApplyToOriginChecked + ? forkedMutation.authorId === loggedInAccountId + ? { applyChangesToOrigin: true } + : { askOriginToApplyChanges: true } + : undefined + ) + switchPreferredSource(mutationToPublish.id, EntitySourceType.Origin) + await deleteLocalMutation(mutationToPublish.id) + onCloseAll() + } catch (error: any) { + console.error(error) + } + } + } + + const handleSaveDropdownChange = (itemId: string) => { + setMode(itemId as MutationModalMode) + } + + return ( + + + {mode === MutationModalMode.Creating + ? `Create your ${itemType}` + : mode === MutationModalMode.Editing + ? `Publish your ${itemType}` + : mode === MutationModalMode.Forking + ? 'Publish as a fork' + : null} + + + {alert ? : null} + + {mode === MutationModalMode.Creating ? ( + <> + + + setImage({ ipfs_cid })} + isDisabled={isFormDisabled} + /> + + setName(e.target.value)} + disabled={isFormDisabled} + /> + + Name* + + + + + + setDescription(e.target.value)} + disabled={isFormDisabled} + /> + Description + + + ) : mode === MutationModalMode.Forking ? ( + <> + + + + {editingMutation.metadata.name} + + +

{editingMutation.metadata.name}

+ + by{' '} + {editingMutation.authorId === loggedInAccountId + ? `me (${loggedInAccountId})` + : editingMutation.authorId} + +
+
+ + {editingMutation.authorId === loggedInAccountId ? null : ( + + Ask Origin to apply changes + setIsApplyToOriginChecked((val) => !val)} + /> + + )} + + + setImage({ ipfs_cid })} + isDisabled={isFormDisabled} + /> + + setName(e.target.value)} + disabled={isFormDisabled} + /> + + Name* + + + + + + setDescription(e.target.value)} + disabled={isFormDisabled} + /> + Description + + + ) : mode === MutationModalMode.Editing ? ( + <> + + + + {newName} + + +

{newName}

+ by me ({loggedInAccountId}) +
+
+ + {forkedMutation ? ( + <> + + + + {forkedMutation.metadata.name} + + +

{forkedMutation.metadata.name}

+ + by{' '} + {forkedMutation.authorId === loggedInAccountId + ? `me (${loggedInAccountId})` + : forkedMutation.authorId} + +
+
+ + + + {forkedMutation.authorId === loggedInAccountId + ? 'Apply changes to Origin' + : 'Ask Origin to apply changes'} + + setIsApplyToOriginChecked((val) => !val)} + /> + + + ) : null} + + + setDescription(e.target.value)} + disabled={isFormDisabled} + /> + Description + + + ) : null} + + + + + +
+ ) +} diff --git a/libs/shared-components/src/multitable-panel/components/mutation-editor-modal.tsx b/libs/shared-components/src/multitable-panel/components/mutation-editor-modal.tsx new file mode 100644 index 00000000..69a49d9b --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/mutation-editor-modal.tsx @@ -0,0 +1,475 @@ +import { ApplicationDto, DocumentDto, EntitySourceType, MutationDto } from '@mweb/backend' +// import { useAccountId } from 'near-social-vm' +import React, { FC, useEffect, useState } from 'react' +import styled from 'styled-components' +import { cloneDeep, mergeDeep } from '../../helpers' +import { useEscape } from '../../hooks/use-escape' +import { Alert, AlertProps } from './alert' +import { ApplicationCardWithDocs, SimpleApplicationCard } from './application-card' +import { Button } from './button' +import { DocumentsModal } from './documents-modal' +import { ModalConfirm } from './modals-confirm' +import { AppInMutation } from '@mweb/backend' +import { Image } from './image' +import { useSaveMutation, useMutableWeb } from '@mweb/engine' +import { ButtonsGroup } from './buttons-group' + +const SelectedMutationEditorWrapper = styled.div` + display: flex; + flex-direction: column; + position: absolute; + top: 70px; + left: 50%; + transform: translateX(-50%); + padding: 20px; + gap: 10px; + border-radius: 10px; + font-family: sans-serif; + border: 1px solid #02193a; + background: #f8f9ff; + width: 400px; + max-height: 70vh; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; +` + +const Close = styled.span` + cursor: pointer; + svg { + margin: 0; + } + &:hover { + opacity: 0.5; + } +` + +const HeaderEditor = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + color: rgba(2, 25, 58, 1); + font-size: 18px; + font-weight: 600; + line-height: 21.09px; + text-align: left; + gap: 20px; + + .edit { + margin-right: auto; + margin-bottom: 2px; + } +` + +const HeaderTitle = styled.div` + color: #02193a; +` + +const AppsList = styled.div` + overflow: hidden; + overflow-y: auto; + max-height: 400px; + display: flex; + flex-direction: column; + gap: 5px; + overscroll-behavior: contain; + + &::-webkit-scrollbar { + cursor: pointer; + width: 4px; + } + + &::-webkit-scrollbar-track { + background: rgb(244 244 244); + background: linear-gradient( + 90deg, + rgb(244 244 244 / 0%) 10%, + rgb(227 227 227 / 100%) 50%, + rgb(244 244 244 / 0%) 90% + ); + } + + &::-webkit-scrollbar-thumb { + width: 4px; + height: 2px; + background: #384bff; + border-radius: 2px; + box-shadow: + 0 2px 6px rgb(0 0 0 / 9%), + 0 2px 2px rgb(38 117 209 / 4%); + } +` + +const BlurredBackground = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgb(255 255 255 / 75%); + backdrop-filter: blur(5px); + border-radius: 9px; + z-index: 3; +` + +const ModalConfirmBackground = styled.div` + position: absolute; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: rgba(255, 255, 255, 0.7); + border-radius: inherit; +` + +const Label = styled.div` + color: #7a818b; + font-size: 8px; + text-transform: uppercase; + font-weight: 700; +` + +const CardWrapper = styled.div` + display: flex; + margin-bottom: 10px; + padding: 6px 10px; + border-radius: 10px; + align-items: center; + justify-content: space-between; + gap: 10px; + background: #fff; +` + +const ImgWrapper = styled.div` + width: 42px; + height: 42px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + } +` + +const TextWrapper = styled.div` + display: flex; + flex-direction: column; + width: calc(100% - 52px); + + p { + font-size: 14px; + font-weight: 600; + color: #02193a; + margin: 0; + overflow-wrap: break-word; + } + + span { + font-size: 10px; + color: #7a818b; + overflow-wrap: break-word; + } +` + +const CloseIcon = () => ( + + + + +) + +const EMPTY_MUTATION_ID = '/mutation/NewMutation' + +const createEmptyMutation = (): MutationDto => ({ + authorId: null, + blockNumber: 0, + id: EMPTY_MUTATION_ID, + localId: 'NewMutation', + timestamp: 0, + source: EntitySourceType.Local, // ToDo: actually source will be changed in click handlers + apps: [], + metadata: { + name: 'New Mutation', + }, + targets: [ + { + namespace: 'engine', + contextType: 'website', + if: { id: { in: [window.location.hostname] } }, + }, + ], +}) + +export interface Props { + apps: ApplicationDto[] + baseMutation: MutationDto | null + localMutations: MutationDto[] + onClose: () => void + loggedInAccountId: string +} + +interface IAlert extends AlertProps { + id: string +} + +const alerts: { [name: string]: IAlert } = { + noWallet: { + id: 'noWallet', + text: 'Connect the NEAR wallet to publish the mutation.', + severity: 'warning', + }, + emptyMutation: { + id: 'emptyMutation', + text: 'A mutation cannot be empty.', + severity: 'warning', + }, + notEditedMutation: { + id: 'notEditedMutation', + text: 'No changes found!', + severity: 'warning', + }, + idIsNotUnique: { + id: 'idIsNotUnique', + text: 'This mutation ID already exists.', + severity: 'warning', + }, + noName: { + id: 'noName', + text: 'Name must be specified.', + severity: 'error', + }, +} + +export const MutationEditorModal: FC = ({ + apps, + baseMutation, + localMutations, + onClose, + loggedInAccountId, +}) => { + const { switchMutation, switchPreferredSource } = useMutableWeb() + // const loggedInAccountId = useAccountId() + const [isModified, setIsModified] = useState(true) + const [appIdToOpenDocsModal, setAppIdToOpenDocsModal] = useState(null) + const [docsForModal, setDocsForModal] = useState(null) + + const { saveMutation, isLoading: isSaving } = useSaveMutation() + + useEscape(onClose) + + // Call `setEditingMutation(chooseEditingMutation())` if you want to revert changes + const chooseEditingMutation = (): MutationDto => + baseMutation + ? baseMutation.source === EntitySourceType.Local + ? baseMutation + : localMutations.find((m) => m.id === baseMutation.id) ?? cloneDeep(baseMutation) + : localMutations.find((m) => m.id === EMPTY_MUTATION_ID) ?? createEmptyMutation() + + const [editingMutation, setEditingMutation] = useState(chooseEditingMutation()) + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false) + + const [alert, setAlert] = useState(null) + + useEffect(() => { + const doChecksForAlerts = (): IAlert | null => { + if (!loggedInAccountId) return alerts.noWallet + if (!editingMutation?.apps || editingMutation?.apps?.length === 0) return alerts.emptyMutation + return null + } + setIsModified(true) + setAlert(doChecksForAlerts()) + }, [loggedInAccountId, editingMutation]) + + useEffect(() => { + setAlert((val) => { + if (!isModified) return alerts.notEditedMutation + return !val || val?.id === 'notEditedMutation' ? null : val + }) + }, [isModified]) + + const isLocalSubmitDisabled = !isModified || (alert && alert.id !== 'noWallet') || isSaving + const isRemoteSubmitDisabled = !isModified || !!alert + + const handleAppCheckboxChange = (appId: string, checked: boolean) => { + setEditingMutation((mut) => { + const apps = checked + ? [...mut.apps, { appId, documentId: null }] + : mut.apps.filter((app) => app.appId !== appId) + return mergeDeep(cloneDeep(mut), { apps }) + }) + } + + const handleDocCheckboxChange = (docId: string | null, appId: string, checked: boolean) => { + setEditingMutation((mut) => { + const apps = checked + ? [...mut.apps, { appId, documentId: docId }] + : mut.apps.filter((app) => app.appId !== appId || app.documentId !== docId) + return mergeDeep(cloneDeep(mut), { apps }) + }) + } + + const handleDocCheckboxBanchChange = (docIds: (string | null)[], appId: string) => { + setEditingMutation((mut) => { + const docIdsToAdd = new Set(docIds) + const apps = mut.apps.filter((_app) => { + if (_app.appId === appId) { + if (docIdsToAdd.has(_app.documentId)) { + docIdsToAdd.delete(_app.documentId) + } else { + return false + } + } + return true + }) + docIdsToAdd.forEach((docId) => { + apps.push({ appId, documentId: docId }) + }) + return mergeDeep(cloneDeep(mut), { apps }) + }) + } + + const handleSaveLocallyClick = () => { + const localMutation = mergeDeep(cloneDeep(editingMutation), { source: EntitySourceType.Local }) + + saveMutation(localMutation) + .then(({ id }) => { + switchMutation(id) + switchPreferredSource(id, EntitySourceType.Local) + }) + .then(onClose) + } + + const handlePublishClick = () => { + setIsConfirmModalOpen(true) + } + + const handleOpenDocumentsModal = (appId: string, docs: DocumentDto[]) => { + setAppIdToOpenDocsModal(appId) + setDocsForModal(docs) + } + + return ( + + + Edit Mutation + + + + + + {alert ? : null} + + {baseMutation ? ( + <> + + + + {baseMutation.metadata.name} + + +

{baseMutation.metadata.name}

+ + by{' '} + {!baseMutation.authorId && !loggedInAccountId + ? 'me' + : (!baseMutation.authorId && loggedInAccountId) || + baseMutation.authorId === loggedInAccountId + ? `me (${loggedInAccountId})` + : baseMutation.authorId} + +
+
+ + ) : null} + + + + {apps.map((app) => + app.permissions.documents ? ( + _app.appId === app.id) + .map((_app) => _app.documentId)} + onOpenDocumentsModal={(docs: DocumentDto[]) => handleOpenDocumentsModal(app.id, docs)} + onDocCheckboxChange={(docId: string | null, isChecked: boolean) => + handleDocCheckboxChange(docId, app.id, isChecked) + } + /> + ) : ( + _app.appId === app.id)} + onChange={(val) => handleAppCheckboxChange(app.id, val)} + /> + ) + )} + + + + + + + + {appIdToOpenDocsModal ? ( + <> + + setAppIdToOpenDocsModal(null)} + chosenDocumentsIds={editingMutation.apps + .filter((_app) => _app.appId === appIdToOpenDocsModal) + .map((_app) => _app.documentId)} + setDocumentsIds={(val: (string | null)[]) => + handleDocCheckboxBanchChange(val, appIdToOpenDocsModal) + } + /> + + ) : null} + + {isConfirmModalOpen && loggedInAccountId && ( + + setIsConfirmModalOpen(false)} + onCloseAll={onClose} + editingMutation={editingMutation} + loggedInAccountId={loggedInAccountId} + /> + + )} +
+ ) +} diff --git a/libs/shared-components/src/multitable-panel/components/upload-image.tsx b/libs/shared-components/src/multitable-panel/components/upload-image.tsx new file mode 100644 index 00000000..854d4410 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/components/upload-image.tsx @@ -0,0 +1,88 @@ +import React, { FC } from 'react' +import styled from 'styled-components' +import { ipfsUpload } from '../../helpers' +import { Image } from './image' + +const InputContainer = styled.div` + display: flex; + gap: 6px; + align-self: center; +` + +const CustomFileUpload = styled.label` + display: flex; + justify-content: center; + align-items: center; + width: 42px; + height: 42px; + border: none; + background: #f8f9ff; + cursor: pointer; + box-sizing: border-box; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + } +` + +const UploadInput = styled.input` + display: none; +` + +const UploadIcon = styled.div`` + +const IconImage = () => ( + + + +) + +interface Props { + onImageChange: (event: any) => Promise + ipfsCid: string | undefined + isDisabled: boolean +} + +export const InputImage: FC = ({ onImageChange, ipfsCid, isDisabled }) => { + const image = { + ipfs_cid: ipfsCid, + } + + const handleImageChange = async (event: any) => { + const file = event.target.files[0] + try { + const cid = await ipfsUpload(file) + await onImageChange(cid) + } catch (error) { + console.error('Error uploading image:', error) + } + } + + return ( + + + + {image?.ipfs_cid ? ( + + ) : ( + + + + )} + + + ) +} diff --git a/libs/shared-components/src/multitable-panel/index.tsx b/libs/shared-components/src/multitable-panel/index.tsx new file mode 100644 index 00000000..a41edab6 --- /dev/null +++ b/libs/shared-components/src/multitable-panel/index.tsx @@ -0,0 +1,79 @@ +import { useNotifications, useViewAllNotifications } from '@mweb/engine' +import React, { FC, useMemo, useRef, useState } from 'react' +import { Space, Typography, Spin, Flex } from 'antd' +import styled from 'styled-components' + +import { Dropdown } from './components/dropdown' + +const { Text } = Typography + +const FeedContainer = styled(Space)` + overflow: hidden; + overflow-y: auto; + height: auto; + transition: all 0.2s ease; + width: 100%; + gap: 10px; +` + +const SpinContainer = styled(Flex)` + transition: all 0.2s ease; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +` + +const Loader = () => ( + + + +) + +const MultitablePanel: FC<{ + loggedInAccountId: string + connectWallet: (() => Promise) | undefined + handleMutateButtonClick: () => void +}> = ({ loggedInAccountId, connectWallet, handleMutateButtonClick }) => { + const [isWaiting, setWaiting] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + const overlayRef = useRef(null) + + const handleSignIn = async () => { + setWaiting(true) + try { + connectWallet && (await connectWallet()) + } finally { + setWaiting(false) + } + } + + return ( + + {/* {isLoading || isWaiting || isViewAllLoading ? ( + + ) : !loggedInAccountId ? ( + + {!!connectWallet ? ( + + ) : ( + 'Login ' + )} + to see more notifications + + ) : ( + <> + + + )} */} + + + ) +} + +export default MultitablePanel diff --git a/libs/shared-components/src/notifications/notification-feed.tsx b/libs/shared-components/src/notifications/notification-feed.tsx index 65941982..8cd4d4f0 100644 --- a/libs/shared-components/src/notifications/notification-feed.tsx +++ b/libs/shared-components/src/notifications/notification-feed.tsx @@ -7,8 +7,6 @@ import styled from 'styled-components' const { Text } = Typography const FeedContainer = styled(Space)` - overflow: hidden; - overflow-y: auto; height: 100%; transition: all 0.2s ease; width: 100%; @@ -85,7 +83,7 @@ const NotificationFeed: FC<{ - New ({newNotifications.length}) + New ({newNotifications.length - 1}) {newNotifications.length ? (