diff --git a/web/src/app.tsx b/web/src/app.tsx index e8021b47b0..51d593f73c 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; import Loading from "@reearth/beta/components/Loading"; -import NotificationBanner from "@reearth/classic/components/organisms/Notification"; +import NotificationBanner from "@reearth/beta/features/Notification"; import { Provider as I18nProvider } from "@reearth/services/i18n"; import { AuthProvider } from "./services/auth"; diff --git a/web/src/beta/components/Icon/Icons/alert.svg b/web/src/beta/components/Icon/Icons/alert.svg new file mode 100644 index 0000000000..6d547c35b8 --- /dev/null +++ b/web/src/beta/components/Icon/Icons/alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/beta/components/Icon/icons.ts b/web/src/beta/components/Icon/icons.ts index a274aac9f6..f72d9ae85d 100644 --- a/web/src/beta/components/Icon/icons.ts +++ b/web/src/beta/components/Icon/icons.ts @@ -32,6 +32,7 @@ import Help from "./Icons/help.svg"; import CheckMark from "./Icons/checkMark.svg"; import Plus from "./Icons/plus.svg"; import Minus from "./Icons/minus.svg"; +import Alert from "./Icons/alert.svg"; // Dataset import File from "./Icons/fileIcon.svg"; @@ -132,6 +133,7 @@ export default { workspaces: Workspaces, checkmark: CheckMark, minus: Minus, + alert: Alert, logo: Logo, logoColorful: LogoColorful, desktop: Desktop, diff --git a/web/src/beta/components/Modal/ModalFrame/index.tsx b/web/src/beta/components/Modal/ModalFrame/index.tsx index 5e683869cd..fd1b30d87a 100644 --- a/web/src/beta/components/Modal/ModalFrame/index.tsx +++ b/web/src/beta/components/Modal/ModalFrame/index.tsx @@ -1,5 +1,5 @@ import useTransition, { TransitionStatus } from "@rot1024/use-transition"; -import { ReactNode, useRef, useCallback } from "react"; +import { ReactNode, useRef, useCallback, useMemo } from "react"; import { useClickAway, useKeyPressEvent } from "react-use"; import Icon from "@reearth/beta/components/Icon"; @@ -19,6 +19,11 @@ const Modal: React.FC = ({ className, size, isVisible, title, onClose, ch const ref = useRef(null); useClickAway(ref, () => onClose?.()); + const modalWidth = useMemo( + () => (size === "sm" ? "416px" : size === "lg" ? "778px" : "572px"), + [size], + ); + const state = useTransition(!!isVisible, 300, { mountOnEnter: true, unmountOnExit: true, @@ -32,17 +37,19 @@ const Modal: React.FC = ({ className, size, isVisible, title, onClose, ch return state === "unmounted" ? null : ( - - {!!title && ( - - - {title} - - {onClose && } - - )} - {children} - + + + {!!title && ( + + + {title} + + {onClose && } + + )} + {children} + + ); }; @@ -61,13 +68,21 @@ const Bg = styled.div<{ state: TransitionStatus }>` opacity: ${({ state }) => (state === "entered" || state === "entering" ? 1 : 0)}; `; -const Wrapper = styled.div<{ size?: string }>` - margin: ${({ size }) => (size === "sm" ? "15%" : size === "lg" ? "4%" : "8%")} auto; - padding-top: 36px; +const CenteredWrapper = styled.div<{ width?: string }>` + margin-left: auto; + margin-right: auto; + height: 100%; + width: ${({ width }) => width}; + position: relative; + display: flex; + flex-direction: column; + justify-content: center; +`; + +const Wrapper = styled.div<{ width?: string }>` border-radius: 8px; background: #161616; - width: ${({ size }) => (size === "sm" ? "372px" : size === "lg" ? "684px" : "620px")}; - position: relative; + width: ${({ width }) => width}; `; const InnerWrapper = styled.div` @@ -93,9 +108,5 @@ const HeaderWrapper = styled.div` border-top-right-radius: 8px; border-top-left-radius: 8px; background: #393939; - position: absolute; - top: 0; - left: 0; - right: 0; `; export default Modal; diff --git a/web/src/beta/components/PopoverMenuContent/index.tsx b/web/src/beta/components/PopoverMenuContent/index.tsx index 44d489b403..350a45f312 100644 --- a/web/src/beta/components/PopoverMenuContent/index.tsx +++ b/web/src/beta/components/PopoverMenuContent/index.tsx @@ -5,6 +5,7 @@ export type MenuItem = { name: string; isSelected?: boolean; icon?: Icons; + disabled?: boolean; onClick?: () => void; }; @@ -44,6 +45,7 @@ const PopoverMenuContent: React.FC = ({ size, width, items }) => { isFirst={i === 0} isLast={i === items.length - 1} size={size} + disabled={!!item.disabled} onClick={item.onClick}> {item.icon && ( @@ -77,14 +79,15 @@ const SRow = styled.button< display: flex; align-items: center; gap: 8px; - color: ${({ theme }) => theme.content.main}; + color: ${({ theme, disabled }) => (disabled ? theme.content.weak : theme.content.main)}; + ${({ disabled }) => disabled && "cursor: default;"} ${({ isFirst }) => !isFirst && "border-top: 1px solid transparent;"} ${({ isLast }) => !isLast && "border-bottom: 1px solid transparent;"} ${({ size }) => stylesBySize[size].row ?? ""} :hover { - background: ${({ isSelected, theme }) => !isSelected && theme.bg[2]}; + background: ${({ isSelected, theme, disabled }) => !disabled && !isSelected && theme.bg[2]}; } `; diff --git a/web/src/beta/features/Editor/hooks.ts b/web/src/beta/features/Editor/hooks.ts index c7e13da533..067c96a063 100644 --- a/web/src/beta/features/Editor/hooks.ts +++ b/web/src/beta/features/Editor/hooks.ts @@ -5,12 +5,34 @@ import { useWidgetAlignEditorActivated } from "@reearth/services/state"; import { Tab } from "../Navbar"; +import { type ProjectType } from "./tabs/publish/Nav"; import { type Device } from "./tabs/widgets/Nav"; export default ({ tab }: { tab: Tab }) => { const [selectedDevice, setDevice] = useState("desktop"); + + const [selectedProjectType, setSelectedProjectType] = useState( + tab === "story" ? "story" : "default", + ); + const [showWidgetEditor, setWidgetEditor] = useWidgetAlignEditorActivated(); + useEffect(() => { + switch (tab) { + case "story": + if (selectedProjectType !== "story") { + setSelectedProjectType("story"); + } + break; + case "scene": + case "widgets": + if (selectedProjectType === "story") { + setSelectedProjectType("default"); + } + break; + } + }, [tab, selectedProjectType, setSelectedProjectType]); + useEffect(() => { if (tab !== "widgets" && showWidgetEditor) { setWidgetEditor(false); @@ -19,6 +41,11 @@ export default ({ tab }: { tab: Tab }) => { const handleDeviceChange = useCallback((newDevice: Device) => setDevice(newDevice), []); + const handleProjectTypeChange = useCallback( + (projectType: ProjectType) => setSelectedProjectType(projectType), + [], + ); + const visualizerWidth = useMemo( () => (tab === "widgets" ? devices[selectedDevice] : "100%"), [tab, selectedDevice], @@ -31,9 +58,11 @@ export default ({ tab }: { tab: Tab }) => { return { selectedDevice, + selectedProjectType, visualizerWidth, showWidgetEditor, handleDeviceChange, + handleProjectTypeChange, handleWidgetEditorToggle, }; }; diff --git a/web/src/beta/features/Editor/index.tsx b/web/src/beta/features/Editor/index.tsx index 3cc0a334ae..52a1f9ec6f 100644 --- a/web/src/beta/features/Editor/index.tsx +++ b/web/src/beta/features/Editor/index.tsx @@ -24,9 +24,11 @@ type Props = { const Editor: React.FC = ({ sceneId, projectId, workspaceId, tab, stories }) => { const { selectedDevice, + selectedProjectType, visualizerWidth, showWidgetEditor, handleDeviceChange, + handleProjectTypeChange, handleWidgetEditorToggle, } = useHooks({ tab }); @@ -55,14 +57,15 @@ const Editor: React.FC = ({ sceneId, projectId, workspaceId, tab, stories const { rightPanel } = useRightPanel({ tab, sceneId }); const { secondaryNavbar } = useSecondaryNavbar({ tab, + projectId, selectedDevice, + selectedProjectType, showWidgetEditor, + handleProjectTypeChange, handleDeviceChange, handleWidgetEditorToggle, }); - const isStory = tab === "story"; - return ( @@ -85,7 +88,7 @@ const Editor: React.FC = ({ sceneId, projectId, workspaceId, tab, stories
{secondaryNavbar} - {isStory && ( + {selectedProjectType === "story" && ( Promise, + onClose?: () => void, + onAliasValidate?: (alias: string) => void, + onCopyToClipBoard?: () => void, +) => { + const [copiedKey, setCopiedKey] = useState(); + const [alias, changeAlias] = useState(defaultAlias); + const [validation, changeValidation] = useState(); + const [statusChanged, setStatusChange] = useState(false); + const [showOptions, setOptions] = useState(!defaultAlias); + const [searchIndex, setSearchIndex] = useState(false); + + useEffect(() => { + setSearchIndex(!!(publishStatus === "published")); + }, [publishStatus]); + + const handleSearchIndexChange = useCallback(() => { + setSearchIndex(!searchIndex); + }, [searchIndex]); + + const handleCopyToClipBoard = useCallback( + (key: keyof CopiedItemKey, value: string | undefined) => + (_: React.MouseEvent) => { + if (!value) return; + setCopiedKey(prevState => ({ + ...prevState, + [key]: true, + })); + navigator.clipboard.writeText(value); + onCopyToClipBoard?.(); + }, + [onCopyToClipBoard], + ); + + const validate = useCallback( + (a?: string) => { + if (!a) { + changeValidation(undefined); + return; + } + if (a.length < 5) { + changeValidation("too short"); + } else if (!/^[A-Za-z0-9_-]*$/.test(a)) { + changeValidation("not match"); + } else { + changeValidation(undefined); + onAliasValidate?.(a); + } + }, + [onAliasValidate], + ); + + const onAliasChange = useCallback( + (value?: string) => { + const a = value || generateAlias(); + changeAlias(a); + validate(a); + }, + [validate], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const handleClose = useCallback(() => { + onClose?.(); + onAliasChange(defaultAlias); + setStatusChange(false); + setOptions(defaultAlias ? false : true); + }, [onClose, defaultAlias]); // eslint-disable-line react-hooks/exhaustive-deps + + const generateAlias = useCallback(() => { + const str = generateRandomString(10); + changeAlias(str); + return str; + }, []); + + useEffect(() => { + onAliasChange(defaultAlias); + }, [defaultAlias]); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePublish = useCallback(async () => { + if (!publishing) return; + const mode = + publishing === "unpublishing" ? "unpublished" : !searchIndex ? "limited" : "published"; + await onPublish?.(mode); + if (publishing === "unpublishing") { + handleClose?.(); + } else { + setStatusChange(true); + } + }, [onPublish, publishing, searchIndex, setStatusChange, handleClose]); + + return { + statusChanged, + alias, + validation, + copiedKey, + showOptions, + searchIndex, + handlePublish, + handleClose, + handleCopyToClipBoard, + handleSearchIndexChange, + setOptions, + }; +}; diff --git a/web/src/beta/features/Editor/tabs/publish/Nav/PublishModal/index.tsx b/web/src/beta/features/Editor/tabs/publish/Nav/PublishModal/index.tsx new file mode 100644 index 0000000000..b792755278 --- /dev/null +++ b/web/src/beta/features/Editor/tabs/publish/Nav/PublishModal/index.tsx @@ -0,0 +1,252 @@ +import { useMemo } from "react"; + +import Button from "@reearth/beta/components/Button"; +import Icon from "@reearth/beta/components/Icon"; +import Modal from "@reearth/beta/components/Modal"; +import ToggleButton from "@reearth/beta/components/properties/Toggle"; +import Text from "@reearth/beta/components/Text"; +import { useT } from "@reearth/services/i18n"; +import { styled, metricsSizes, useTheme } from "@reearth/services/theme"; + +import useHooks, { type PublishStatus } from "./hooks"; + +export type publishingType = "publishing" | "updating" | "unpublishing"; + +type Props = { + isVisible: boolean; + className?: string; + loading?: boolean; + publishStatus?: PublishStatus; + projectId?: string; + projectAlias?: string; + validAlias?: boolean; + publishing?: publishingType; + validatingAlias?: boolean; + url?: string[]; + onPublish: (publishStatus: PublishStatus) => Promise; + onClose?: () => void; + onCopyToClipBoard?: () => void; + onAliasValidate?: (alias: string) => void; +}; + +const PublishModal: React.FC = ({ + isVisible, + loading, + publishing, + publishStatus, + projectAlias, + validAlias, + validatingAlias, + url, + onClose, + onPublish, + onCopyToClipBoard, + onAliasValidate, +}) => { + const t = useT(); + const theme = useTheme(); + + const { + statusChanged, + alias, + validation, + showOptions, + searchIndex, + handlePublish, + handleClose, + handleCopyToClipBoard, + handleSearchIndexChange, + setOptions, + } = useHooks( + publishing, + publishStatus, + projectAlias, + onPublish, + onClose, + onAliasValidate, + onCopyToClipBoard, + ); + + const purl = useMemo(() => { + return (url?.[0] ?? "") + (alias?.replace("/", "") ?? "") + (url?.[1] ?? ""); + }, [alias, url]); + + const embedCode = useMemo( + () => `;`, + [purl], + ); + + const publishDisabled = useMemo( + () => + loading || + (publishing === "unpublishing" && publishStatus === "unpublished") || + ((publishing === "publishing" || publishing === "updating") && + (!alias || !!validation || validatingAlias || !validAlias)), + [alias, loading, publishStatus, publishing, validation, validAlias, validatingAlias], + ); + + const modalTitleText = useMemo(() => { + return statusChanged + ? t("Congratulations!") + : publishing === "publishing" + ? t("Publish your project") + : publishing === "updating" + ? t("Update your project") + : ""; + }, [t, statusChanged, publishing]); + + const primaryButtonText = useMemo(() => { + return statusChanged + ? t("Ok") + : publishing === "publishing" + ? t("Publish") + : publishing === "updating" + ? t("Update") + : t("Continue"); + }, [t, statusChanged, publishing]); + + const secondaryButtonText = useMemo(() => (!statusChanged ? "Cancel" : "Close"), [statusChanged]); + + const updateDescriptionText = useMemo(() => { + return publishing === "updating" + ? t( + "Your published project will be updated. This means all current changes will overwrite the current published project.", + ) + : t( + "Your project will be published. This means anybody with the below URL will be able to view this project.", + ); + }, [t, publishing]); + + return ( + } + button2={ + !statusChanged && ( + + + {t("* Anyone can see your project with this URL")} + + {t("Embed Code")} +
+
+ + {embedCode} + + +
+ + {t("* Please use this code if you want to embed your project into a webpage")} + +
+ + ) : publishing !== "unpublishing" ? ( + <> +
+ {updateDescriptionText} + {url && alias && ( + + + {purl} + + + )} +
+ setOptions(!showOptions)}> + {t("more options")} + + + + + {t("Search engine indexing")} + + + + + ) : ( +
+ + {t("Your project will be unpublished.")} + + {t("This means that anybody with the URL will become unable to view this project.")} + + + {t("**Warning**: This includes websites where this project is embedded.")} + +
+ )} +
+ ); +}; + +export default PublishModal; + +const Section = styled.div<{ disabled?: boolean }>` + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: ${`${metricsSizes["m"]}px`}; + opacity: ${({ disabled }) => disabled && "0.6"}; + cursor: ${({ disabled }) => disabled && "not-allowed"}; +`; + +const Subtitle = styled(Text)` + text-align: left; +`; + +const StyledIcon = styled(Icon)` + margin-bottom: ${`${metricsSizes["xl"]}px`}; +`; + +const PublishLink = styled.a` + text-decoration: none; +`; + +const OptionsToggle = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin: ${`0 0 ${metricsSizes["m"]}px 0`}; + color: ${({ theme }) => theme.classic.main.text}; + cursor: pointer; + user-select: none; +`; + +const ArrowIcon = styled(Icon)<{ open?: boolean }>` + transition: transform 0.15s ease; + transform: ${({ open }) => + open ? "translateY(10%) rotate(90deg)" : "translateY(0) rotate(180deg)"}; +`; + +const UrlText = styled(Text)` + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: ${`${metricsSizes["2xl"]}px 0`}; +`; + +const HideableSection = styled(Section)<{ showOptions?: boolean }>` + display: ${props => (props.showOptions ? null : "none")}; +`; + +const Wrapper = styled.div` + display: flex; + justify-content: space-between; + align-content: center; +`; diff --git a/web/src/beta/features/Editor/tabs/publish/Nav/hooks.ts b/web/src/beta/features/Editor/tabs/publish/Nav/hooks.ts new file mode 100644 index 0000000000..aca67bd43a --- /dev/null +++ b/web/src/beta/features/Editor/tabs/publish/Nav/hooks.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import generateRandomString from "@reearth/beta/utils/generate-random-string"; +import { useProjectFetcher } from "@reearth/services/api"; + +import { publishingType } from "./PublishModal"; +import { type PublishStatus } from "./PublishModal/hooks"; + +export default ({ projectId }: { projectId?: string }) => { + const { useProjectQuery, useProjectAliasCheckLazyQuery, usePublishProject } = useProjectFetcher(); + const { project } = useProjectQuery(projectId); + + const [publishing, setPublishing] = useState("unpublishing"); + + const [dropdownOpen, setDropdown] = useState(false); + const [modalOpen, setModal] = useState(false); + + const generateAlias = useCallback(() => generateRandomString(10), []); + + const [validAlias, setValidAlias] = useState(false); + const alias = useMemo(() => project?.alias ?? generateAlias(), [project?.alias, generateAlias]); + + const [checkProjectAlias, { loading: validatingAlias, data: checkProjectAliasData }] = + useProjectAliasCheckLazyQuery(); + + const handleProjectAliasCheck = useCallback( + (a: string) => { + if (project?.alias === a) { + setValidAlias(true); + } else { + checkProjectAlias({ variables: { alias: a } }); + } + }, + [project?.alias, checkProjectAlias], + ); + + useEffect(() => { + setValidAlias( + !validatingAlias && + !!project && + !!checkProjectAliasData && + (project.alias === checkProjectAliasData.checkProjectAlias.alias || + checkProjectAliasData.checkProjectAlias.available), + ); + }, [validatingAlias, checkProjectAliasData, project]); + + const publishStatus: PublishStatus = useMemo(() => { + const status = + project?.publishmentStatus === "PUBLIC" + ? "published" + : project?.publishmentStatus === "LIMITED" + ? "limited" + : "unpublished"; + return status; + }, [project?.publishmentStatus]); + + const handleProjectPublish = useCallback( + async (publishStatus: PublishStatus) => { + await usePublishProject(publishStatus, projectId, alias); + }, + [projectId, alias, usePublishProject], + ); + + const handleOpenProjectSettings = useCallback(() => { + setDropdown(false); + }, []); + + const handleModalOpen = useCallback((p: publishingType) => { + setPublishing(p); + setDropdown(false); + setModal(true); + }, []); + + const handleModalClose = useCallback(() => { + setModal(false); + }, []); + + return { + publishing, + publishStatus, + dropdownOpen, + modalOpen, + alias, + validAlias, + validatingAlias, + handleModalOpen, + handleModalClose, + setDropdown, + handleProjectPublish, + handleProjectAliasCheck, + handleOpenProjectSettings, + }; +}; diff --git a/web/src/beta/features/Editor/tabs/publish/Nav/index.tsx b/web/src/beta/features/Editor/tabs/publish/Nav/index.tsx new file mode 100644 index 0000000000..aaed90d134 --- /dev/null +++ b/web/src/beta/features/Editor/tabs/publish/Nav/index.tsx @@ -0,0 +1,170 @@ +import { useMemo } from "react"; + +import Icon from "@reearth/beta/components/Icon"; +import * as Popover from "@reearth/beta/components/Popover"; +import PopoverMenuContent from "@reearth/beta/components/PopoverMenuContent"; +// import TabButton from "@reearth/beta/components/TabButton"; +import Text from "@reearth/beta/components/Text"; +import SecondaryNav from "@reearth/beta/features/Editor/SecondaryNav"; +import { config } from "@reearth/services/config"; +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; + +import useHooks from "./hooks"; +import PublishModal from "./PublishModal"; +import { PublishStatus } from "./PublishModal/hooks"; + +export { navbarHeight } from "@reearth/beta/features/Editor/SecondaryNav"; + +export type ProjectType = "default" | "story"; + +type Props = { + projectId?: string; + selectedProjectType?: ProjectType; + onProjectTypeChange: (type: ProjectType) => void; +}; + +const Nav: React.FC = ({ projectId }) => { + const t = useT(); + + const { + publishing, + publishStatus, + dropdownOpen, + modalOpen, + alias, + validAlias, + validatingAlias, + handleModalOpen, + handleModalClose, + setDropdown, + handleProjectPublish, + handleProjectAliasCheck, + handleOpenProjectSettings, + } = useHooks({ projectId }); + + const text = useMemo( + () => + publishStatus === "published" || publishStatus === "limited" + ? t("Published") + : t("Unpublished"), + [publishStatus, t], + ); + + return ( + <> + + + {/* onProjectTypeChange("default")} + /> + onProjectTypeChange("story")} + /> */} + + setDropdown(!dropdownOpen)} + placement="bottom-end"> + + setDropdown(!dropdownOpen)}> + + + {text} + + + + + + handleModalOpen("unpublishing"), + }, + { + name: t("Publish"), + onClick: () => + handleModalOpen( + publishStatus === "limited" || publishStatus === "published" + ? "updating" + : "publishing", + ), + }, + { + name: t("Publishing Settings"), + onClick: () => handleOpenProjectSettings(), + }, + ]} + /> + + + + + + ); +}; + +export default Nav; + +const StyledSecondaryNav = styled(SecondaryNav)` + display: flex; + justify-content: space-between; + align-items: center; + padding-right: 8px; + padding-left: 8px; +`; + +const LeftSection = styled.div` + display: flex; + gap: 4px; +`; + +const Publishing = styled.div` + display: flex; + align-items: center; + gap: 10px; + color: ${({ theme }) => theme.content.weak}; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.4s; + + :hover { + background: ${({ theme }) => theme.bg[2]}; + p { + transition: all 0.4s; + color: ${({ theme }) => theme.content.main}; + } + * { + opacity: 1; + } + } +`; + +const Status = styled.div<{ status?: PublishStatus }>` + opacity: 0.5; + background: ${({ theme, status }) => + status === "published" || status === "limited" ? theme.select.strong : theme.bg[4]}; + width: 8px; + height: 8px; + border-radius: 50%; +`; diff --git a/web/src/beta/features/Editor/useSecondaryNavbar.tsx b/web/src/beta/features/Editor/useSecondaryNavbar.tsx index ce3e9f7f27..9a38e24f14 100644 --- a/web/src/beta/features/Editor/useSecondaryNavbar.tsx +++ b/web/src/beta/features/Editor/useSecondaryNavbar.tsx @@ -1,21 +1,27 @@ import { ReactNode, useMemo } from "react"; -import SecondaryNav from "@reearth/beta/features/Editor/SecondaryNav"; +import PublishNav, { type ProjectType } from "@reearth/beta/features/Editor/tabs/publish/Nav"; import WidgetNav, { type Device } from "@reearth/beta/features/Editor/tabs/widgets/Nav"; import { Tab } from "@reearth/beta/features/Navbar"; type Props = { tab: Tab; - selectedDevice: Device; + projectId?: string; showWidgetEditor?: boolean; + selectedDevice: Device; + selectedProjectType?: ProjectType; + handleProjectTypeChange: (type: ProjectType) => void; handleDeviceChange: (device: Device) => void; handleWidgetEditorToggle: () => void; }; export default ({ tab, - selectedDevice, + projectId, showWidgetEditor, + selectedDevice, + selectedProjectType, + handleProjectTypeChange, handleDeviceChange, handleWidgetEditorToggle, }: Props) => { @@ -31,13 +37,28 @@ export default ({ /> ); case "publish": - return TODO: Publishing navbar; + return ( + + ); case "scene": case "story": default: return undefined; } - }, [tab, selectedDevice, showWidgetEditor, handleDeviceChange, handleWidgetEditorToggle]); + }, [ + tab, + projectId, + selectedDevice, + selectedProjectType, + showWidgetEditor, + handleDeviceChange, + handleWidgetEditorToggle, + handleProjectTypeChange, + ]); return { secondaryNavbar, diff --git a/web/src/beta/features/Notification/hooks.ts b/web/src/beta/features/Notification/hooks.ts new file mode 100644 index 0000000000..2062649b51 --- /dev/null +++ b/web/src/beta/features/Notification/hooks.ts @@ -0,0 +1,119 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; + +import { useT, useLang } from "@reearth/services/i18n"; +import { useError, useNotification, Notification } from "@reearth/services/state"; + +export type PolicyItems = + | "layer" + | "asset" + | "dataset" + | "createProject" + | "publishProject" + | "member"; + +const policyItems: PolicyItems[] = [ + "layer", + "asset", + "dataset", + "createProject", + "publishProject", + "member", +]; + +export default () => { + const t = useT(); + const currentLanguage = useLang(); + const [error, setError] = useError(); + const [notification, setNotification] = useNotification(); + const [visible, changeVisibility] = useState(false); + + const policyLimitNotifications = window.REEARTH_CONFIG?.policy?.limitNotifications; + + const errorHeading = t("Error"); + const warningHeading = t("Warning"); + const noticeHeading = t("Notice"); + + const notificationHeading = useMemo( + () => + notification?.type === "error" + ? errorHeading + : notification?.type === "warning" + ? warningHeading + : noticeHeading, + [notification?.type, errorHeading, warningHeading, noticeHeading], + ); + + const resetNotification = useCallback(() => setNotification(undefined), [setNotification]); + + const setModal = useCallback((show: boolean) => { + changeVisibility(show); + }, []); + + useEffect(() => { + if (!error) return; + if (error.message?.includes("policy violation") && error.message) { + const limitedItem = policyItems.find(i => error.message?.includes(i)); + const policyItem = + limitedItem && policyLimitNotifications ? policyLimitNotifications[limitedItem] : undefined; + const message = policyItem + ? typeof policyItem === "string" + ? policyItem + : policyItem[currentLanguage] + : t( + "You have reached a policy limit. Please contact an administrator of your Re:Earth system.", + ); + + setNotification({ + type: "info", + heading: noticeHeading, + text: message, + duration: "persistent", + }); + } else { + setNotification({ + type: "error", + heading: errorHeading, + text: t("Something went wrong. Please try again later."), + }); + } + setError(undefined); + }, [ + error, + currentLanguage, + policyLimitNotifications, + errorHeading, + noticeHeading, + setError, + setNotification, + t, + ]); + + useEffect(() => { + if (!notification) return; + if (notification.duration === "persistent") return; + + let notificationTimeout = 5000; + if (notification.duration) { + notificationTimeout = notification.duration; + } + const timerID = setTimeout(() => { + changeVisibility(false); + }, notificationTimeout); + return () => clearTimeout(timerID); + }, [notification]); + + useEffect(() => { + changeVisibility(!!notification); + }, [notification]); + + return { + visible, + notification: { + type: notification?.type, + heading: notificationHeading, + text: notification?.text, + } as Notification, + setModal, + resetNotification, + }; +}; diff --git a/web/src/beta/features/Notification/index.tsx b/web/src/beta/features/Notification/index.tsx new file mode 100644 index 0000000000..69e649cde2 --- /dev/null +++ b/web/src/beta/features/Notification/index.tsx @@ -0,0 +1,85 @@ +import Icon from "@reearth/beta/components/Icon"; +import Text from "@reearth/beta/components/Text"; +import { styled, useTheme } from "@reearth/services/theme"; + +import useHooks from "./hooks"; + +export type NotificationType = "error" | "warning" | "info" | "success"; +export type Notification = { + type: NotificationType; + heading?: string; + text: string; +}; + +const NotificationBanner: React.FC = () => { + const { visible, notification, setModal, resetNotification } = useHooks(); + const theme = useTheme(); + + return ( + + + + {notification?.heading} + + { + setModal?.(false); + resetNotification?.(); + }} + /> + + + {notification?.text} + + + ); +}; + +export default NotificationBanner; + +const StyledNotificationBanner = styled.div<{ + type?: NotificationType; + visible?: boolean; +}>` + display: flex; + flex-direction: column; + position: absolute; + top: 49px; + right: 0; + width: 312px; + padding: 8px 12px; + background-color: ${({ type, theme }) => + type === "error" + ? theme.classic.notification.errorBg + : type === "warning" + ? theme.classic.notification.warningBg + : type === "success" + ? theme.classic.notification.successBg + : theme.classic.notification.infoBg}; + color: ${({ theme }) => theme.classic.notification.text}; + z-index: ${({ theme, visible }) => (visible ? theme.classic.zIndexes.notificationBar : 0)}; + opacity: ${({ visible }) => (visible ? "1" : "0")}; + transition: all 0.5s; + pointer-events: ${({ visible }) => (visible ? "auto" : "none")}; +`; + +const HeadingArea = styled.div` + display: flex; + justify-content: space-between; + width: 100%; +`; + +const CloseBtn = styled(Icon)` + cursor: pointer; +`; diff --git a/web/src/services/api/projectApi.ts b/web/src/services/api/projectApi.ts index 10bdcc741f..c95b77312d 100644 --- a/web/src/services/api/projectApi.ts +++ b/web/src/services/api/projectApi.ts @@ -1,14 +1,24 @@ -import { useMutation, useQuery } from "@apollo/client"; -import { useCallback } from "react"; +import { useLazyQuery, useMutation, useQuery } from "@apollo/client"; +import { useCallback, useMemo } from "react"; -import { ProjectPayload, Visualizer } from "@reearth/services/gql/__gen__/graphql"; -import { CREATE_PROJECT, GET_PROJECT } from "@reearth/services/gql/queries/project"; +import { type PublishStatus } from "@reearth/beta/features/Editor/tabs/publish/Nav/PublishModal/hooks"; +import { + ProjectPayload, + PublishmentStatus, + Visualizer, +} from "@reearth/services/gql/__gen__/graphql"; +import { + CHECK_PROJECT_ALIAS, + CREATE_PROJECT, + GET_PROJECT, + PUBLISH_PROJECT, +} from "@reearth/services/gql/queries/project"; import { CREATE_SCENE } from "@reearth/services/gql/queries/scene"; import { useT } from "@reearth/services/i18n"; import { useNotification } from "../state"; -import { MutationReturn, QueryReturn } from "./types"; +import { MutationReturn } from "./types"; export type Project = ProjectPayload["project"]; @@ -16,17 +26,24 @@ export default () => { const t = useT(); const [, setNotification] = useNotification(); - const useProjectQuery = useCallback((projectId?: string): QueryReturn> => { + const useProjectQuery = useCallback((projectId?: string) => { const { data, ...rest } = useQuery(GET_PROJECT, { variables: { projectId: projectId ?? "" }, skip: !projectId, }); - const project = data?.node?.__typename === "Project" ? data.node : undefined; + const project = useMemo( + () => (data?.node?.__typename === "Project" ? data.node : undefined), + [data?.node], + ); return { project, ...rest }; }, []); + const useProjectAliasCheckLazyQuery = useCallback(() => { + return useLazyQuery(CHECK_PROJECT_ALIAS); + }, []); + const [createNewProject] = useMutation(CREATE_PROJECT); const [createScene] = useMutation(CREATE_SCENE, { refetchQueries: ["GetProjects"] }); @@ -72,8 +89,53 @@ export default () => { [createNewProject, createScene, setNotification, t], ); + const [publishProjectMutation] = useMutation(PUBLISH_PROJECT, { + refetchQueries: ["GetProject"], + }); + + const usePublishProject = useCallback( + async (s: PublishStatus, projectId?: string, alias?: string) => { + if (!projectId) return; + + const gqlStatus = toGqlStatus(s); + + const { data, errors } = await publishProjectMutation({ + variables: { projectId, alias, status: gqlStatus }, + }); + + if (errors || !data?.publishProject) { + console.log("GraphQL: Failed to publish project", errors); + setNotification({ type: "error", text: t("Failed to publish project.") }); + + return { status: "error" }; + } + + setNotification({ + type: s === "limited" ? "success" : s == "published" ? "success" : "info", + text: + s === "limited" + ? t("Successfully published your project!") + : s == "published" + ? t("Successfully published your project with search engine indexing!") + : t("Successfully unpublished your project. Now nobody can access your project."), + }); + return { data: data.publishProject.project, status: "success" }; + }, + [publishProjectMutation, t, setNotification], + ); + return { useProjectQuery, + useProjectAliasCheckLazyQuery, useCreateProject, + usePublishProject, }; }; + +const toGqlStatus = (status?: PublishStatus) => { + return status === "limited" + ? PublishmentStatus.Limited + : status == "published" + ? PublishmentStatus.Public + : PublishmentStatus.Private; +}; diff --git a/web/src/services/i18n/translations/en.yml b/web/src/services/i18n/translations/en.yml index bc199943e1..9b31f18ae7 100644 --- a/web/src/services/i18n/translations/en.yml +++ b/web/src/services/i18n/translations/en.yml @@ -1,7 +1,36 @@ Not found: Not found +Published: Published +Unpublished: Unpublished +Scene: Scene +Story: Story +Unpublish: Unpublish +Publish: Publish +Publishing Settings: Publishing Settings +Congratulations!: Congratulations! +Publish your project: Publish your project +Update your project: Update your project +Ok: Ok +Update: Update +Continue: Continue +Your published project will be updated. This means all current changes will overwrite the current published project.: >- + Your published project will be updated. This means all current changes will + overwrite the current published project. +Your project will be published. This means anybody with the below URL will be able to view this project.: >- + Your project will be published. This means anybody with the below URL will be + able to view this project. +Your project has been published!: Your project has been published! +Public URL: Public URL +Copy: Copy +'* Anyone can see your project with this URL': '* Anyone can see your project with this URL' +Embed Code: Embed Code +'* Please use this code if you want to embed your project into a webpage': '* Please use this code if you want to embed your project into a webpage' +more options: more options +Search engine indexing: Search engine indexing +Your project will be unpublished.: Your project will be unpublished. +This means that anybody with the URL will become unable to view this project.: This means that anybody with the URL will become unable to view this project. +'**Warning**: This includes websites where this project is embedded.': '**Warning**: This includes websites where this project is embedded.' New Page: New Page New Swipe: New Swipe -Story: Story Page: Page Align System: Align System Widget Manager: Widget Manager @@ -24,9 +53,12 @@ Datasets: Datasets Plugins: Plugins Manage projects: Manage projects Documentation: Documentation -Scene: Scene Widgets: Widgets -Publish: Publish +Error: Error +Warning: Warning +Notice: Notice +You have reached a policy limit. Please contact an administrator of your Re:Earth system.: '' +Something went wrong. Please try again later.: Something went wrong. Please try again later. Double click here to write.: Double click here to write. Move mouse here and click "+" to add content: Move mouse here and click "+" to add content aria-label-compass: compass @@ -40,9 +72,6 @@ Play timeline: Play timeline ellipse: ellipse Open timeline: Open timeline Drop here: Drop here -Continue: Continue -Published: Published -Unpublished: Unpublished not set: not set Welcome: Welcome Log in to Re:Earth to continue.: Log in to Re:Earth to continue. @@ -143,9 +172,7 @@ Other Source: Other Source No Dataset is here: No Dataset is here Export type: Export type Export: Export -Update: Update Preview: Preview -Unpublish: Unpublish Layer selection: Layer selection Selectable Layers: Selectable Layers Add: Add @@ -213,28 +240,7 @@ Gap Spacing: '' Align Center: '' Background Color: '' Enable Editor Mode: Enable Editor Mode -Congratulations!: Congratulations! -Publish your project: Publish your project -Update your project: Update your project -Ok: Ok -Your published project will be updated. This means all current changes will overwrite the current published project.: >- - Your published project will be updated. This means all current changes will - overwrite the current published project. -Your project will be published. This means anybody with the below URL will be able to view this project.: >- - Your project will be published. This means anybody with the below URL will be - able to view this project. Close: Close -Your project has been published!: Your project has been published! -Public URL: Public URL -Copy: Copy -'* Anyone can see your project with this URL': '* Anyone can see your project with this URL' -Embed Code: Embed Code -'* Please use this code if you want to embed your project into a webpage': '* Please use this code if you want to embed your project into a webpage' -more options: more options -Search engine indexing: Search engine indexing -Your project will be unpublished.: Your project will be unpublished. -This means that anybody with the URL will become unable to view this project.: This means that anybody with the URL will become unable to view this project. -'**Warning**: This includes websites where this project is embedded.': '**Warning**: This includes websites where this project is embedded.' Add a tag group: Add a tag group Add a tag: Add a tag Auto: Auto @@ -257,7 +263,6 @@ Workspace List: Workspace List Assets: Assets Project List: Project List Public: Public -Notice: Notice Most project settings are hidden when the project is archived. Please unarchive the project to view and edit these settings.: >- Most project settings are hidden when the project is archived. Please unarchive the project to view and edit these settings. @@ -376,10 +381,6 @@ Infobox: Infobox Tag: Tag Same label tag already exist. Please type different label.: Same label tag already exist. Please type different label. Tag group has tags, you need to remove all tags under the tag group: Tag group has tags, you need to remove all tags under the tag group -Error: Error -Warning: Warning -You have reached a policy limit. Please contact an administrator of your Re:Earth system.: '' -Something went wrong. Please try again later.: Something went wrong. Please try again later. Failed to update account name.: Failed to update account name. Failed to update password.: Failed to update password. Successfully updated password!: Successfully updated password! @@ -410,6 +411,7 @@ Successfully added member(s) to the workspace!: Successfully added member(s) to Failed to delete member from the workspace.: Failed to delete member from the workspace. Successfully removed member from the workspace.: Successfully removed member from the workspace. Some error has occurred. Please wait a moment and try again.: Some error has occurred. Please wait a moment and try again. +Failed to publish project.: Failed to publish project. Failed to update property.: Failed to update property Failed to create story.: Failed to create story. Failed to create page.: Failed to create page. diff --git a/web/src/services/i18n/translations/ja.yml b/web/src/services/i18n/translations/ja.yml index 0e3ac0151b..c696c14344 100644 --- a/web/src/services/i18n/translations/ja.yml +++ b/web/src/services/i18n/translations/ja.yml @@ -1,7 +1,32 @@ Not found: ページが見つかりません +Published: 一般公開 +Unpublished: 非公開 +Scene: シーン +Story: ストーリー +Unpublish: 非公開にする +Publish: 公開 +Publishing Settings: 公開設定 +Congratulations!: おめでとうございます! +Publish your project: プロジェクトを公開する +Update your project: プロジェクトを更新する +Ok: 確認 +Update: 更新する +Continue: 続ける +Your published project will be updated. This means all current changes will overwrite the current published project.: 公開されたプロジェクトが更新されます。公開されているプロジェクトへ現在の内容が上書きされます。 +Your project will be published. This means anybody with the below URL will be able to view this project.: プロジェクトが公開されます。URLを知っている人は誰でもこのプロジェクトを見ることができるようになります。 +Your project has been published!: プロジェクトが公開されました。 +Public URL: 公開URL +Copy: コピー +'* Anyone can see your project with this URL': '* このリンクを知っているインターネット上の全員が閲覧できます' +Embed Code: 埋め込み用コード +'* Please use this code if you want to embed your project into a webpage': その他のWebサイトでこのプロジェクトを埋め込む場合は、こちらのコードを使用してください +more options: その他の設定 +Search engine indexing: 検索可能にする +Your project will be unpublished.: プロジェクトを非公開にする +This means that anybody with the URL will become unable to view this project.: URLを知っている人もこのプロジェクトを見ることができなくなります。 +'**Warning**: This includes websites where this project is embedded.': '**Warning**このプロジェクトを埋め込んだWebサイトへ影響を及ぼす可能性があります。' New Page: 新しいページ New Swipe: 新しいスワイプ -Story: ストーリー Page: ページ Align System: アラインシステム Widget Manager: ウィジェット管理 @@ -24,9 +49,12 @@ Datasets: データセット Plugins: プラグイン Manage projects: プロジェクト管理 Documentation: ヘルプ -Scene: シーン Widgets: ウィジェット -Publish: 公開 +Error: エラー +Warning: 注意 +Notice: 通知 +You have reached a policy limit. Please contact an administrator of your Re:Earth system.: 現在のポリシーの上限に達しました。システム管理者にお問い合わせください。 +Something went wrong. Please try again later.: 何らかの問題が発生しました。しばらく経ってからお試しください。 Double click here to write.: ダブルクリックで入力 Move mouse here and click "+" to add content: マウスをここへ、“+”をクリックしコンテンツを追加 aria-label-compass: コンパス @@ -40,9 +68,6 @@ Play timeline: タイムラインを再生する ellipse: 円錐 Open timeline: タイムラインを開く Drop here: ここにドロップ -Continue: 続ける -Published: 一般公開 -Unpublished: 非公開 not set: 未設定 Welcome: ようこそ Log in to Re:Earth to continue.: Re:Earthにログイン @@ -133,9 +158,7 @@ Other Source: その他 No Dataset is here: データセットは存在しません Export type: ファイル形式 Export: エクスポート -Update: 更新する Preview: プレビュー -Unpublish: 非公開にする Layer selection: レイヤーを選択 Selectable Layers: 選択可能なレイヤー Add: 追加 @@ -199,24 +222,7 @@ Gap Spacing: 間隔 Align Center: 中央に配置 Background Color: 背景色 Enable Editor Mode: ウィジェットの配置を編集 -Congratulations!: おめでとうございます! -Publish your project: プロジェクトを公開する -Update your project: プロジェクトを更新する -Ok: 確認 -Your published project will be updated. This means all current changes will overwrite the current published project.: 公開されたプロジェクトが更新されます。公開されているプロジェクトへ現在の内容が上書きされます。 -Your project will be published. This means anybody with the below URL will be able to view this project.: プロジェクトが公開されます。URLを知っている人は誰でもこのプロジェクトを見ることができるようになります。 Close: 閉じる -Your project has been published!: プロジェクトが公開されました。 -Public URL: 公開URL -Copy: コピー -'* Anyone can see your project with this URL': '* このリンクを知っているインターネット上の全員が閲覧できます' -Embed Code: 埋め込み用コード -'* Please use this code if you want to embed your project into a webpage': その他のWebサイトでこのプロジェクトを埋め込む場合は、こちらのコードを使用してください -more options: その他の設定 -Search engine indexing: 検索可能にする -Your project will be unpublished.: プロジェクトを非公開にする -This means that anybody with the URL will become unable to view this project.: URLを知っている人もこのプロジェクトを見ることができなくなります。 -'**Warning**: This includes websites where this project is embedded.': '**Warning**このプロジェクトを埋め込んだWebサイトへ影響を及ぼす可能性があります。' Add a tag group: タググループを追加 Add a tag: タグを追加 Auto: 自動 @@ -237,7 +243,6 @@ Workspace List: ワークスペース一覧 Assets: アセット Project List: プロジェクト一覧 Public: 公開設定 -Notice: 通知 Most project settings are hidden when the project is archived. Please unarchive the project to view and edit these settings.: プロジェクトをアーカイブ化すると、削除とアーカイブ化解除以外の編集は行えません。再度編集可能な状態にするには、プロジェクトのアーカイブ化を解除してください。 Basic Authorization: ベーシック認証 Enable basic authorization: ベーシック認証を有効化する @@ -337,10 +342,6 @@ Infobox: インフォボックス Tag: タグ Same label tag already exist. Please type different label.: 同じラベルのタグがすでに存在します。ラベルを変更してもう一度お試しください。 Tag group has tags, you need to remove all tags under the tag group: タググループ内のタグをすべて削除してからタググループを削除してください。 -Error: エラー -Warning: 注意 -You have reached a policy limit. Please contact an administrator of your Re:Earth system.: 現在のポリシーの上限に達しました。システム管理者にお問い合わせください。 -Something went wrong. Please try again later.: 何らかの問題が発生しました。しばらく経ってからお試しください。 Failed to update account name.: アカウント名の変更に失敗しました。 Failed to update password.: パスワードの更新に失敗しました。 Successfully updated password!: パスワードの更新が完了しました。 @@ -371,6 +372,7 @@ Successfully added member(s) to the workspace!: このワークスペースに Failed to delete member from the workspace.: メンバーの削除に失敗しました。 Successfully removed member from the workspace.: このワークスペースからひとりのメンバーを削除しました。 Some error has occurred. Please wait a moment and try again.: エラーが発生しました。少し待ってからもう一度試してみてください。 +Failed to publish project.: プロジェクト公開に失敗しました。 Failed to update property.: プロパティのアップデートに失敗しました。 Failed to create story.: ストーリーの作成に失敗しました。 Failed to create page.: ページの作成に失敗しました。