diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx index 3fbab8362..4af4c57b9 100644 --- a/app/actions/definitions/navigation.tsx +++ b/app/actions/definitions/navigation.tsx @@ -10,6 +10,7 @@ import { KeyboardIcon, EmailIcon, LogoutIcon, + ProfileIcon, } from "outline-icons"; import * as React from "react"; import { @@ -29,7 +30,8 @@ import { } from "~/actions/sections"; import history from "~/utils/history"; import { - settingsPath, + organizationSettingsPath, + profileSettingsPath, homePath, searchPath, draftsPath, @@ -103,9 +105,16 @@ export const navigateToSettings = createAction({ name: ({ t }) => t("Settings"), section: NavigationSection, shortcut: ["g", "s"], - iconInContextMenu: false, icon: , - perform: () => history.push(settingsPath()), + perform: () => history.push(organizationSettingsPath()), +}); + +export const navigateToProfileSettings = createAction({ + name: ({ t }) => t("Profile"), + section: NavigationSection, + iconInContextMenu: false, + icon: , + perform: () => history.push(profileSettingsPath()), }); export const openAPIDocumentation = createAction({ diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 12c31551e..11ad15b11 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -16,6 +16,7 @@ import { newDocumentPath, settingsPath, } from "~/utils/routeHelpers"; +import Fade from "./Fade"; import withStores from "./withStores"; const DocumentHistory = React.lazy( @@ -74,10 +75,12 @@ class AuthenticatedLayout extends React.Component { } const sidebar = showSidebar ? ( - - - - + + + + + + ) : undefined; const rightRail = ( diff --git a/app/components/Avatar/Avatar.tsx b/app/components/Avatar/Avatar.tsx index 7121ec60a..290a298d4 100644 --- a/app/components/Avatar/Avatar.tsx +++ b/app/components/Avatar/Avatar.tsx @@ -11,6 +11,7 @@ type Props = { icon?: React.ReactNode; user?: User; alt?: string; + showBorder?: boolean; onClick?: React.MouseEventHandler; className?: string; }; @@ -29,12 +30,13 @@ class Avatar extends React.Component { }; render() { - const { src, icon, ...rest } = this.props; + const { src, icon, showBorder, ...rest } = this.props; return ( {icon && {icon}} @@ -59,12 +61,14 @@ const IconWrapper = styled.div` height: 20px; `; -const CircleImg = styled.img<{ size: number }>` +const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>` display: block; width: ${(props) => props.size}px; height: ${(props) => props.size}px; border-radius: 50%; - border: 2px solid ${(props) => props.theme.background}; + border: 2px solid + ${(props) => + props.$showBorder === false ? "transparent" : props.theme.background}; flex-shrink: 0; `; diff --git a/app/components/Bubble.tsx b/app/components/Bubble.tsx deleted file mode 100644 index 6cba8e80b..000000000 --- a/app/components/Bubble.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"; -import styled from "styled-components"; -import { bounceIn } from "~/styles/animations"; - -type Props = { - count: number; -}; - -const Bubble = ({ count }: Props) => { - if (!count) { - return null; - } - - return {count}; -}; - -const Count = styled.div` - animation: ${bounceIn} 600ms; - transform-origin: center center; - color: ${(props) => props.theme.white}; - background: ${(props) => props.theme.slateDark}; - display: inline-block; - font-feature-settings: "tnum"; - font-weight: 600; - font-size: 9px; - white-space: nowrap; - vertical-align: baseline; - min-width: 16px; - min-height: 16px; - line-height: 16px; - border-radius: 8px; - text-align: center; - padding: 0 4px; - margin-left: 8px; - user-select: none; -`; - -export default Bubble; diff --git a/app/components/EmojiIcon.tsx b/app/components/EmojiIcon.tsx new file mode 100644 index 000000000..a31a79ccd --- /dev/null +++ b/app/components/EmojiIcon.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import styled from "styled-components"; + +type Props = { + /* The emoji to render */ + emoji: string; + /* The size of the emoji, 24px is default to match standard icons */ + size?: number; +}; + +/** + * EmojiIcon is a component that renders an emoji in the size of a standard icon + * in a way that can be used wherever an Icon would be. + */ +export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) { + return ( + + {emoji} + + ); +} + +const Span = styled.span<{ $size: number }>` + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + width: ${(props) => props.$size}px; + height: ${(props) => props.$size}px; + text-indent: -0.15em; + font-size: 14px; +`; diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 0a63e9e25..4e980c0e2 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -118,7 +118,7 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>` padding: 12px; transition: all 100ms ease-out; transform: translate3d(0, 0, 0); - min-height: 56px; + min-height: 64px; justify-content: flex-start; @supports (backdrop-filter: blur(20px)) { diff --git a/app/components/Sidebar/Main.tsx b/app/components/Sidebar/App.tsx similarity index 74% rename from app/components/Sidebar/Main.tsx rename to app/components/Sidebar/App.tsx index 9a60ee347..140dd4855 100644 --- a/app/components/Sidebar/Main.tsx +++ b/app/components/Sidebar/App.tsx @@ -1,44 +1,40 @@ import { useKBar } from "kbar"; import { observer } from "mobx-react"; -import { - EditIcon, - SearchIcon, - ShapesIcon, - HomeIcon, - SettingsIcon, -} from "outline-icons"; +import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons"; import * as React from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { useTranslation } from "react-i18next"; import { useHistory, useLocation } from "react-router-dom"; import styled from "styled-components"; -import Bubble from "~/components/Bubble"; import Flex from "~/components/Flex"; import Scrollable from "~/components/Scrollable"; +import Text from "~/components/Text"; import { inviteUser } from "~/actions/definitions/users"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; import AccountMenu from "~/menus/AccountMenu"; +import OrganizationMenu from "~/menus/OrganizationMenu"; import { homePath, draftsPath, templatesPath, - settingsPath, searchPath, } from "~/utils/routeHelpers"; +import Avatar from "../Avatar"; +import TeamLogo from "../TeamLogo"; import Sidebar from "./Sidebar"; import ArchiveLink from "./components/ArchiveLink"; import Collections from "./components/Collections"; import Section from "./components/Section"; import SidebarAction from "./components/SidebarAction"; +import SidebarButton from "./components/SidebarButton"; import SidebarLink from "./components/SidebarLink"; import Starred from "./components/Starred"; -import TeamButton from "./components/TeamButton"; import TrashLink from "./components/TrashLink"; -function MainSidebar() { +function AppSidebar() { const { t } = useTranslation(); const { ui, policies, documents } = useStores(); const team = useCurrentTeam(); @@ -76,18 +72,19 @@ function MainSidebar() { {dndArea && ( - + {(props) => ( - + } showDisclosure /> )} - - + +
} label={ - + {t("Drafts")} - - + + {documents.totalDrafts} + + } /> )}
- +
+ +
@@ -138,23 +139,41 @@ function MainSidebar() { )} - } - exact={false} - label={t("Settings")} - />
+ + {(props) => ( + + } + /> + )} +
)}
); } -const Drafts = styled(Flex)` - height: 24px; +const StyledTeamLogo = styled(TeamLogo)` + margin-right: 4px; `; -export default observer(MainSidebar); +const StyledAvatar = styled(Avatar)` + margin-left: 4px; +`; + +const Drafts = styled(Text)` + margin: 0 4px; +`; + +export default observer(AppSidebar); diff --git a/app/components/Sidebar/Settings.tsx b/app/components/Sidebar/Settings.tsx index 6297c710f..2553c5d44 100644 --- a/app/components/Sidebar/Settings.tsx +++ b/app/components/Sidebar/Settings.tsx @@ -9,7 +9,7 @@ import { GroupIcon, LinkIcon, TeamIcon, - ExpandedIcon, + BackIcon, BeakerIcon, DownloadIcon, } from "outline-icons"; @@ -27,8 +27,8 @@ import useStores from "~/hooks/useStores"; import Sidebar from "./Sidebar"; import Header from "./components/Header"; import Section from "./components/Section"; +import SidebarButton from "./components/SidebarButton"; import SidebarLink from "./components/SidebarLink"; -import TeamButton from "./components/TeamButton"; import Version from "./components/Version"; const isHosted = env.DEPLOYMENT === "hosted"; @@ -40,21 +40,17 @@ function SettingsSidebar() { const { policies } = useStores(); const can = policies.abilities(team.id); - const returnToDashboard = React.useCallback(() => { + const returnToApp = React.useCallback(() => { history.push("/home"); }, [history]); return ( - - {t("Return to App")} - - } - teamName={team.name} - logoUrl={team.avatarUrl} - onClick={returnToDashboard} + } + onClick={returnToApp} + minHeight={48} /> @@ -165,13 +161,8 @@ function SettingsSidebar() { ); } -const BackIcon = styled(ExpandedIcon)` - transform: rotate(90deg); - margin-left: -8px; -`; - -const ReturnToApp = styled(Flex)` - height: 16px; +const StyledBackIcon = styled(BackIcon)` + margin-left: 4px; `; export default observer(SettingsSidebar); diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx index 955d94a8d..e97e540cb 100644 --- a/app/components/Sidebar/Sidebar.tsx +++ b/app/components/Sidebar/Sidebar.tsx @@ -5,7 +5,6 @@ import { Portal } from "react-portal"; import { useLocation } from "react-router-dom"; import styled, { useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; -import Fade from "~/components/Fade"; import Flex from "~/components/Flex"; import usePrevious from "~/hooks/usePrevious"; import useStores from "~/hooks/useStores"; @@ -14,7 +13,6 @@ import ResizeBorder from "./components/ResizeBorder"; import Toggle, { ToggleButton, Positioner } from "./components/Toggle"; const ANIMATION_MS = 250; -let isFirstRender = true; type Props = { children: React.ReactNode; @@ -126,7 +124,6 @@ const Sidebar = React.forwardRef( React.useEffect(() => { if (location !== previousLocation) { - isFirstRender = false; ui.hideMobileSidebar(); } }, [ui, location, previousLocation]); @@ -146,28 +143,6 @@ const Sidebar = React.forwardRef( [width, theme.sidebarCollapsedWidth, collapsed] ); - const content = ( - <> - {ui.mobileSidebarVisible && ( - - - - )} - {children} - - {ui.sidebarCollapsed && !ui.isEditing && ( - - )} - - ); - return ( <> ( $collapsed={collapsed} column > - {isFirstRender ? {content} : content} + {ui.mobileSidebarVisible && ( + + + + )} + {children} + + {ui.sidebarCollapsed && !ui.isEditing && ( + + )} {!ui.isEditing && ( { + if (expanded) { + setOpenedOnce(true); + } + }, [expanded]); + const manualSort = collection.sort.field === "index"; const can = policies.abilities(collection.id); const belowCollectionIndex = belowCollection ? belowCollection.index : null; @@ -118,7 +125,7 @@ function CollectionLink({ // Drop to reorder document const [{ isOverReorder }, dropToReorder] = useDrop({ accept: "document", - drop: async (item: DragObject) => { + drop: (item: DragObject) => { if (!collection) { return; } @@ -131,11 +138,11 @@ function CollectionLink({ // Drop to reorder collection const [ - { isCollectionDropping, isDraggingAnotherCollection }, + { isCollectionDropping, isDraggingAnyCollection }, dropToReorderCollection, ] = useDrop({ accept: "collection", - drop: async (item: DragObject) => { + drop: (item: DragObject) => { collections.move( item.id, fractionalIndex(collection.index, belowCollectionIndex) @@ -147,9 +154,9 @@ function CollectionLink({ (!belowCollection || item.id !== belowCollection.id) ); }, - collect: (monitor) => ({ + collect: (monitor: DropTargetMonitor) => ({ isCollectionDropping: monitor.isOver(), - isDraggingAnotherCollection: monitor.canDrop(), + isDraggingAnyCollection: monitor.getItemType() === "collection", }), }); @@ -194,8 +201,7 @@ function CollectionLink({ collection.sort, ]); - const isDraggingAnyCollection = - isDraggingAnotherCollection || isCollectionDragging; + const displayDocumentLinks = expanded && !isCollectionDragging; React.useEffect(() => { // If we're viewing a starred document through the starred menu then don't @@ -204,21 +210,14 @@ function CollectionLink({ return; } - if (isDraggingAnyCollection) { - setExpanded(false); - } else { - setExpanded(collection.id === ui.activeCollectionId); + if (collection.id === ui.activeCollectionId) { + setExpanded(true); } - }, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]); + }, [collection.id, ui.activeCollectionId, search]); return ( <> -
+ { + event.preventDefault(); + setExpanded((prev) => !prev); + }} icon={ - + } showActions={menuOpen} isActiveDrop={isOver && canDrop} @@ -242,12 +249,13 @@ function CollectionLink({ /> } exact={false} - depth={0.5} + depth={0} menu={ - !isEditing && ( + !isEditing && + !isDraggingAnyCollection && ( <> - {can.update && ( - - {expanded && manualSort && ( - + + + {openedOnce && ( + + {manualSort && ( + + )} + {collectionDocuments.map((node, index) => ( + + ))} + )} {isDraggingAnyCollection && ( )} -
- {expanded && - collectionDocuments.map((node, index) => ( - - ))} + + ` + display: ${(props) => (props.$open ? "block" : "none")}; +`; + const Draggable = styled("div")<{ $isDragging: boolean; $isMoving: boolean }>` opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; pointer-events: ${(props) => (props.$isMoving ? "none" : "auto")}; `; -const CollectionSortMenuWithMargin = styled(CollectionSortMenu)` - margin-right: 4px; -`; - export default observer(CollectionLink); diff --git a/app/components/Sidebar/components/Collections.tsx b/app/components/Sidebar/components/Collections.tsx index b9cc9e937..270d61f3c 100644 --- a/app/components/Sidebar/components/Collections.tsx +++ b/app/components/Sidebar/components/Collections.tsx @@ -1,6 +1,5 @@ import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; -import { CollapsedIcon } from "outline-icons"; import * as React from "react"; import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; @@ -13,9 +12,10 @@ import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import CollectionLink from "./CollectionLink"; import DropCursor from "./DropCursor"; +import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; import SidebarAction from "./SidebarAction"; -import SidebarLink, { DragObject } from "./SidebarLink"; +import { DragObject } from "./SidebarLink"; function Collections() { const [isFetching, setFetching] = React.useState(false); @@ -52,7 +52,10 @@ function Collections() { load(); }, [collections, isFetching, showToast, fetchError, t]); - const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({ + const [ + { isCollectionDropping, isDraggingAnyCollection }, + dropToReorderCollection, + ] = useDrop({ accept: "collection", drop: async (item: DragObject) => { collections.move( @@ -65,16 +68,19 @@ function Collections() { }, collect: (monitor) => ({ isCollectionDropping: monitor.isOver(), + isDraggingAnyCollection: monitor.getItemType() === "collection", }), }); const content = ( <> - + {isDraggingAnyCollection && ( + + )} {orderedCollections.map((collection: Collection, index: number) => ( ))} - + ); if (!collections.isLoaded || fetchError) { return ( - } - /> +
{t("Collections")}
); @@ -103,19 +106,18 @@ function Collections() { return ( - setExpanded((prev) => !prev)} - label={t("Collections")} - icon={} - /> - {expanded && (isPreloaded ? content : {content})} +
setExpanded((prev) => !prev)} expanded={expanded}> + {t("Collections")} +
+ {expanded && ( + {isPreloaded ? content : {content}} + )}
); } -const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>` - transition: transform 100ms ease, fill 50ms !important; - ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; +const Relative = styled.div` + position: relative; `; export default observer(Collections); diff --git a/app/components/Sidebar/components/Disclosure.ts b/app/components/Sidebar/components/Disclosure.ts deleted file mode 100644 index 4c17623a1..000000000 --- a/app/components/Sidebar/components/Disclosure.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CollapsedIcon } from "outline-icons"; -import styled from "styled-components"; - -const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>` - transition: transform 100ms ease, fill 50ms !important; - position: absolute; - left: -24px; - - ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; -`; - -export default Disclosure; diff --git a/app/components/Sidebar/components/Disclosure.tsx b/app/components/Sidebar/components/Disclosure.tsx new file mode 100644 index 000000000..c57ec5571 --- /dev/null +++ b/app/components/Sidebar/components/Disclosure.tsx @@ -0,0 +1,54 @@ +import { CollapsedIcon } from "outline-icons"; +import * as React from "react"; +import styled, { css } from "styled-components"; +import NudeButton from "~/components/NudeButton"; + +type Props = { + onClick?: React.MouseEventHandler; + expanded: boolean; + root?: boolean; +}; + +function Disclosure({ onClick, root, expanded, ...rest }: Props) { + return ( + + ); +} + +const Button = styled(NudeButton)<{ $root?: boolean }>` + position: absolute; + left: -24px; + flex-shrink: 0; + color: ${(props) => props.theme.textSecondary}; + + &:hover { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.sidebarControlHoverBackground}; + } + + ${(props) => + props.$root && + css` + opacity: 0; + left: -16px; + + &:hover { + opacity: 1; + background: none; + } + `} +`; + +const StyledCollapsedIcon = styled(CollapsedIcon)<{ + expanded?: boolean; +}>` + transition: opacity 100ms ease, transform 100ms ease, fill 50ms !important; + ${(props) => !props.expanded && "transform: rotate(-90deg);"}; +`; + +// Enables identifying this component within styled components +const StyledDisclosure = styled(Disclosure)``; + +export default StyledDisclosure; diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 63aa5677c..2d4285428 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -16,7 +16,6 @@ import useStores from "~/hooks/useStores"; import DocumentMenu from "~/menus/DocumentMenu"; import { NavigationNode } from "~/types"; import { newDocumentPath } from "~/utils/routeHelpers"; -import Disclosure from "./Disclosure"; import DropCursor from "./DropCursor"; import DropToImport from "./DropToImport"; import EditableTitle from "./EditableTitle"; @@ -81,7 +80,9 @@ function DocumentLink( isActiveDocument) ); }, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]); + const [expanded, setExpanded] = React.useState(showChildren); + const [openedOnce, setOpenedOnce] = React.useState(expanded); React.useEffect(() => { if (showChildren) { @@ -89,6 +90,12 @@ function DocumentLink( } }, [showChildren]); + React.useEffect(() => { + if (expanded) { + setOpenedOnce(true); + } + }, [expanded]); + // when the last child document is removed, // also close the local folder state to closed React.useEffect(() => { @@ -98,7 +105,7 @@ function DocumentLink( }, [expanded, hasChildDocuments]); const handleDisclosureClick = React.useCallback( - (ev: React.SyntheticEvent) => { + (ev) => { ev.preventDefault(); ev.stopPropagation(); setExpanded(!expanded); @@ -270,6 +277,8 @@ function DocumentLink( t("Untitled"); const can = policies.abilities(node.id); + const isExpanded = expanded && !isDragging; + const hasChildren = nodeChildren.length > 0; return ( <> @@ -283,6 +292,8 @@ function DocumentLink(
- {hasChildDocuments && ( - - )} - - + } isActive={(match, location) => !!match && location.search !== "?starred" @@ -351,26 +354,32 @@ function DocumentLink( )} - {expanded && - !isDragging && - nodeChildren.map((childNode, index) => ( - - ))} + {openedOnce && ( + + {nodeChildren.map((childNode, index) => ( + + ))} + + )} ); } +const Folder = styled.div<{ $open?: boolean }>` + display: ${(props) => (props.$open ? "block" : "none")}; +`; + const Relative = styled.div` position: relative; `; diff --git a/app/components/Sidebar/components/DropCursor.tsx b/app/components/Sidebar/components/DropCursor.tsx index b7410422e..c4b4bd443 100644 --- a/app/components/Sidebar/components/DropCursor.tsx +++ b/app/components/Sidebar/components/DropCursor.tsx @@ -23,7 +23,7 @@ const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>` width: 100%; height: 14px; background: transparent; - ${(props) => (props.position === "top" ? "top: 25px;" : "bottom: -7px;")} + ${(props) => (props.position === "top" ? "top: -7px;" : "bottom: -7px;")} ::after { background: ${(props) => props.theme.slateDark}; diff --git a/app/components/Sidebar/components/Header.ts b/app/components/Sidebar/components/Header.ts deleted file mode 100644 index 77b0b1f7c..000000000 --- a/app/components/Sidebar/components/Header.ts +++ /dev/null @@ -1,14 +0,0 @@ -import styled from "styled-components"; -import Flex from "~/components/Flex"; - -const Header = styled(Flex)` - font-size: 11px; - font-weight: 600; - user-select: none; - text-transform: uppercase; - color: ${(props) => props.theme.sidebarText}; - letter-spacing: 0.04em; - margin: 4px 12px; -`; - -export default Header; diff --git a/app/components/Sidebar/components/Header.tsx b/app/components/Sidebar/components/Header.tsx new file mode 100644 index 000000000..32f361610 --- /dev/null +++ b/app/components/Sidebar/components/Header.tsx @@ -0,0 +1,64 @@ +import { CollapsedIcon } from "outline-icons"; +import * as React from "react"; +import styled from "styled-components"; + +type Props = { + onClick?: React.MouseEventHandler; + expanded?: boolean; + children: React.ReactNode; +}; + +export function Header({ onClick, expanded, children }: Props) { + return ( +

+ +

+ ); +} + +const Button = styled.button` + display: inline-flex; + align-items: center; + font-size: 13px; + font-weight: 600; + user-select: none; + color: ${(props) => props.theme.textTertiary}; + letter-spacing: 0.03em; + margin: 0; + padding: 4px 2px 4px 12px; + height: 22px; + border: 0; + background: none; + border-radius: 4px; + -webkit-appearance: none; + transition: all 100ms ease; + + &:not(:disabled):hover, + &:not(:disabled):active { + color: ${(props) => props.theme.textSecondary}; + cursor: pointer; + } +`; + +const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>` + transition: opacity 100ms ease, transform 100ms ease, fill 50ms !important; + ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; + opacity: 0; +`; + +const H3 = styled.h3` + margin: 0; + + &:hover { + ${Disclosure} { + opacity: 1; + } + } +`; + +export default Header; diff --git a/app/components/Sidebar/components/SidebarButton.tsx b/app/components/Sidebar/components/SidebarButton.tsx new file mode 100644 index 000000000..1307ea63e --- /dev/null +++ b/app/components/Sidebar/components/SidebarButton.tsx @@ -0,0 +1,81 @@ +import { ExpandedIcon, MoreIcon } from "outline-icons"; +import * as React from "react"; +import styled from "styled-components"; +import Flex from "~/components/Flex"; + +type Props = { + title: React.ReactNode; + image: React.ReactNode; + minHeight?: number; + rounded?: boolean; + showDisclosure?: boolean; + showMoreMenu?: boolean; + onClick: React.MouseEventHandler; +}; + +const SidebarButton = React.forwardRef( + ( + { + showDisclosure, + showMoreMenu, + image, + title, + minHeight = 0, + ...rest + }: Props, + ref + ) => ( + + + {image} + {title} + + {showDisclosure && } + {showMoreMenu && } + + ) +); + +const Title = styled(Flex)` + color: ${(props) => props.theme.text}; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +`; + +const Wrapper = styled(Flex)<{ minHeight: number }>` + padding: 8px 4px; + font-size: 15px; + font-weight: 500; + border-radius: 4px; + margin: 8px; + color: ${(props) => props.theme.textTertiary}; + border: 0; + background: none; + flex-shrink: 0; + min-height: ${(props) => props.minHeight}px; + + -webkit-appearance: none; + text-decoration: none; + text-align: left; + overflow: hidden; + user-select: none; + cursor: pointer; + + &:active, + &:hover { + color: ${(props) => props.theme.sidebarText}; + transition: background 100ms ease-in-out; + background: ${(props) => props.theme.sidebarActiveBackground}; + } +`; + +export default SidebarButton; diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index 2d36a5be0..21b57ea19 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -1,10 +1,10 @@ -import { transparentize } from "polished"; import * as React from "react"; import styled, { useTheme, css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import EventBoundary from "~/components/EventBoundary"; import NudeButton from "~/components/NudeButton"; import { NavigationNode } from "~/types"; +import Disclosure from "./Disclosure"; import NavLink, { Props as NavLinkProps } from "./NavLink"; export type DragObject = NavigationNode & { @@ -19,11 +19,14 @@ type Props = Omit & { innerRef?: (arg0: HTMLElement | null | undefined) => void; onClick?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; + onDisclosureClick?: React.MouseEventHandler; icon?: React.ReactNode; label?: React.ReactNode; menu?: React.ReactNode; showActions?: boolean; active?: boolean; + /* If set, a disclosure will be rendered to the left of any icon */ + expanded?: boolean; isActiveDrop?: boolean; isDraft?: boolean; depth?: number; @@ -50,6 +53,8 @@ function SidebarLink( href, depth, className, + expanded, + onDisclosureClick, ...rest }: Props, ref: React.RefObject @@ -66,10 +71,10 @@ function SidebarLink( () => ({ fontWeight: 600, color: theme.text, - background: theme.sidebarItemBackground, + background: theme.sidebarActiveBackground, ...style, }), - [theme, style] + [theme.text, theme.sidebarActiveBackground, style] ); return ( @@ -90,14 +95,34 @@ function SidebarLink( ref={ref} {...rest} > - {icon && {icon}} - + + {expanded !== undefined && ( + + )} + {icon && {icon}} + + {menu && {menu}} ); } +const Content = styled.span` + display: flex; + align-items: start; + position: relative; + width: 100%; + + ${Disclosure} { + margin-top: 2px; + } +`; + // accounts for whitespace around icon export const IconWrapper = styled.span` margin-left: -4px; @@ -112,6 +137,7 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>` position: absolute; top: 4px; right: 4px; + gap: 4px; color: ${(props) => props.theme.textTertiary}; transition: opacity 50ms; @@ -158,10 +184,8 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>` transition: fill 50ms; } - &:focus { - color: ${(props) => props.theme.text}; - background: ${(props) => - transparentize("0.25", props.theme.sidebarItemBackground)}; + &:hover svg { + display: inline; } & + ${Actions} { @@ -170,16 +194,9 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>` } } - &:focus + ${Actions} { - ${NudeButton} { - background: ${(props) => - transparentize("0.25", props.theme.sidebarItemBackground)}; - } - } - &[aria-current="page"] + ${Actions} { ${NudeButton} { - background: ${(props) => props.theme.sidebarItemBackground}; + background: ${(props) => props.theme.sidebarActiveBackground}; } } @@ -202,6 +219,12 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>` props.$isActiveDrop ? props.theme.white : props.theme.text}; } } + + &:hover { + ${Disclosure} { + opacity: 1; + } + } `; const Label = styled.div` @@ -209,6 +232,7 @@ const Label = styled.div` width: 100%; max-height: 4.8em; line-height: 1.6; + * { unicode-bidi: plaintext; } diff --git a/app/components/Sidebar/components/Starred.tsx b/app/components/Sidebar/components/Starred.tsx index 96b66a41a..10cbbaa5e 100644 --- a/app/components/Sidebar/components/Starred.tsx +++ b/app/components/Sidebar/components/Starred.tsx @@ -1,6 +1,5 @@ import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; -import { CollapsedIcon } from "outline-icons"; import * as React from "react"; import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; @@ -10,8 +9,8 @@ import Flex from "~/components/Flex"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import DropCursor from "./DropCursor"; +import Header from "./Header"; import PlaceholderCollections from "./PlaceholderCollections"; -import Section from "./Section"; import SidebarLink from "./SidebarLink"; import StarredLink from "./StarredLink"; @@ -119,71 +118,64 @@ function Starred() { }), }); - const content = stars.orderedData.slice(0, upperBound).map((star) => { - const document = documents.get(star.documentId); - - return document ? ( - - ) : null; - }); - if (!stars.orderedData.length) { return null; } return ( -
- - } - /> - {expanded && ( - <> - +
+ {t("Starred")} +
+ {expanded && ( + + + {stars.orderedData.slice(0, upperBound).map((star) => { + const document = documents.get(star.documentId); + + return document ? ( + + ) : null; + })} + {show === "More" && !isFetching && ( + - {content} - {show === "More" && !isFetching && ( - - )} - {show === "Less" && !isFetching && ( - - )} - {(isFetching || fetchError) && !stars.orderedData.length && ( - - - - )} - - )} -
-
+ )} + {show === "Less" && !isFetching && ( + + )} + {(isFetching || fetchError) && !stars.orderedData.length && ( + + + + )} + + )} + ); } -const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>` - transition: transform 100ms ease, fill 50ms !important; - ${({ expanded }) => !expanded && "transform: rotate(-90deg);"}; +const Relative = styled.div` + position: relative; `; export default observer(Starred); diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index 8ce6bb9bb..ebc022705 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -1,19 +1,18 @@ import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; +import { StarredIcon } from "outline-icons"; import * as React from "react"; import { useEffect, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; -import { useTranslation } from "react-i18next"; -import styled from "styled-components"; -import { MAX_TITLE_LENGTH } from "@shared/constants"; +import styled, { useTheme } from "styled-components"; +import parseTitle from "@shared/utils/parseTitle"; import Star from "~/models/Star"; +import EmojiIcon from "~/components/EmojiIcon"; import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; import useStores from "~/hooks/useStores"; import DocumentMenu from "~/menus/DocumentMenu"; -import Disclosure from "./Disclosure"; import DropCursor from "./DropCursor"; -import EditableTitle from "./EditableTitle"; import SidebarLink from "./SidebarLink"; type Props = { @@ -27,24 +26,22 @@ type Props = { function StarredLink({ depth, - title, to, documentId, + title, collectionId, star, }: Props) { - const { t } = useTranslation(); - const { collections, documents, policies } = useStores(); + const theme = useTheme(); + const { collections, documents } = useStores(); const collection = collections.get(collectionId); const document = documents.get(documentId); const [expanded, setExpanded] = useState(false); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); - const canUpdate = policies.abilities(documentId).update; const childDocuments = collection ? collection.getDocumentChildren(documentId) : []; const hasChildDocuments = childDocuments.length > 0; - const [isEditing, setIsEditing] = React.useState(false); useEffect(() => { async function load() { @@ -57,7 +54,7 @@ function StarredLink({ }, [collection, collectionId, collections, document, documentId, documents]); const handleDisclosureClick = React.useCallback( - (ev: React.MouseEvent) => { + (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); setExpanded((prevExpanded) => !prevExpanded); @@ -65,29 +62,6 @@ function StarredLink({ [] ); - const handleTitleChange = React.useCallback( - async (title: string) => { - if (!document) { - return; - } - await documents.update( - { - id: document.id, - text: document.text, - title, - }, - { - lastRevision: document.revision, - } - ); - }, - [documents, document] - ); - - const handleTitleEditing = React.useCallback((isEditing: boolean) => { - setIsEditing(isEditing); - }, []); - // Draggable const [{ isDragging }, drag] = useDrag({ type: "star", @@ -96,7 +70,7 @@ function StarredLink({ isDragging: !!monitor.isDragging(), }), canDrag: () => { - return depth === 2; + return depth === 0; }, }); @@ -116,36 +90,34 @@ function StarredLink({ }), }); + const { emoji } = parseTitle(title); + const label = emoji ? title.replace(emoji, "") : title; + return ( <> + ) : ( + + ) + ) : undefined + } isActive={(match, location) => !!match && location.search === "?starred" } - label={ - <> - {hasChildDocuments && ( - - )} - - - } + label={depth === 0 ? label : title} exact={false} showActions={menuOpen} menu={ - document && !isEditing ? ( + document ? ( ( ; - logoUrl: string; -}; - -const TeamButton = React.forwardRef( - ({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => ( - -
- - - - {teamName} {showDisclosure && } - - {subheading} - -
-
- ) -); - -const Disclosure = styled(ExpandedIcon)` - position: absolute; - right: 0; - top: 0; -`; - -const Subheading = styled.div` - padding-left: 10px; - font-size: 11px; - text-transform: uppercase; - font-weight: 500; - white-space: nowrap; - color: ${(props) => props.theme.sidebarText}; -`; - -const TeamName = styled.div` - position: relative; - padding-left: 10px; - padding-right: 24px; - font-weight: 600; - color: ${(props) => props.theme.text}; - white-space: nowrap; - text-decoration: none; - font-size: 16px; - - text-align: left; - text-overflow: ellipsis; - overflow: hidden; - width: 100%; -`; - -const Wrapper = styled.div` - flex-shrink: 0; - overflow: hidden; -`; - -const Header = styled.button` - display: flex; - align-items: center; - background: none; - line-height: inherit; - border: 0; - padding: 8px; - margin: 8px; - border-radius: 4px; - cursor: pointer; - width: calc(100% - 16px); - - &:active, - &:hover { - transition: background 100ms ease-in-out; - background: ${(props) => props.theme.sidebarItemBackground}; - } -`; - -export default observer(TeamButton); diff --git a/app/components/Sidebar/index.ts b/app/components/Sidebar/index.ts index 3d6822466..cb972c423 100644 --- a/app/components/Sidebar/index.ts +++ b/app/components/Sidebar/index.ts @@ -1,3 +1,3 @@ -import Sidebar from "./Main"; +import Sidebar from "./App"; export default Sidebar; diff --git a/app/components/TeamLogo.ts b/app/components/TeamLogo.ts index 2eb5d1442..6d0efc0c3 100644 --- a/app/components/TeamLogo.ts +++ b/app/components/TeamLogo.ts @@ -6,7 +6,7 @@ const TeamLogo = styled.img<{ width?: number; height?: number; size?: string }>` height: ${(props) => props.height ? `${props.height}px` : props.size || "38px"}; border-radius: 4px; - background: ${(props) => props.theme.background}; + background: white; border: 1px solid ${(props) => props.theme.divider}; overflow: hidden; flex-shrink: 0; diff --git a/app/menus/AccountMenu.tsx b/app/menus/AccountMenu.tsx index 4603fe23c..2c6eb30c9 100644 --- a/app/menus/AccountMenu.tsx +++ b/app/menus/AccountMenu.tsx @@ -2,13 +2,10 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { MenuButton, useMenuState } from "reakit/Menu"; -import styled from "styled-components"; import ContextMenu from "~/components/ContextMenu"; import Template from "~/components/ContextMenu/Template"; -import { createAction } from "~/actions"; -import { development } from "~/actions/definitions/debug"; import { - navigateToSettings, + navigateToProfileSettings, openKeyboardShortcuts, openChangelog, openAPIDocumentation, @@ -17,9 +14,7 @@ import { logout, } from "~/actions/definitions/navigation"; import { changeTheme } from "~/actions/definitions/settings"; -import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePrevious from "~/hooks/usePrevious"; -import useSessions from "~/hooks/useSessions"; import useStores from "~/hooks/useStores"; import separator from "~/menus/separator"; @@ -28,15 +23,12 @@ type Props = { }; function AccountMenu(props: Props) { - const [sessions] = useSessions(); const menu = useMenuState({ - unstable_offset: [8, 0], - placement: "bottom-start", + placement: "bottom-end", modal: true, }); const { ui } = useStores(); const { theme } = ui; - const team = useCurrentTeam(); const previousTheme = usePrevious(theme); const { t } = useTranslation(); @@ -47,39 +39,19 @@ function AccountMenu(props: Props) { }, [menu, theme, previousTheme]); const actions = React.useMemo(() => { - const otherSessions = sessions.filter( - (session) => session.teamId !== team.id && session.url !== team.url - ); - return [ - navigateToSettings, openKeyboardShortcuts, openAPIDocumentation, separator(), openChangelog, openFeedbackUrl, openBugReportUrl, - development, changeTheme, + navigateToProfileSettings, separator(), - ...(otherSessions.length - ? [ - createAction({ - name: t("Switch team"), - section: "account", - children: otherSessions.map((session) => ({ - id: session.url, - name: session.name, - section: "account", - icon: , - perform: () => (window.location.href = session.url), - })), - }), - ] - : []), logout, ]; - }, [team.id, team.url, sessions, t]); + }, []); return ( <> @@ -91,10 +63,4 @@ function AccountMenu(props: Props) { ); } -const Logo = styled("img")` - border-radius: 2px; - width: 24px; - height: 24px; -`; - export default observer(AccountMenu); diff --git a/app/menus/OrganizationMenu.tsx b/app/menus/OrganizationMenu.tsx new file mode 100644 index 000000000..2b2031b6d --- /dev/null +++ b/app/menus/OrganizationMenu.tsx @@ -0,0 +1,82 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { MenuButton, useMenuState } from "reakit/Menu"; +import styled from "styled-components"; +import ContextMenu from "~/components/ContextMenu"; +import Template from "~/components/ContextMenu/Template"; +import { createAction } from "~/actions"; +import { navigateToSettings, logout } from "~/actions/definitions/navigation"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import usePrevious from "~/hooks/usePrevious"; +import useSessions from "~/hooks/useSessions"; +import useStores from "~/hooks/useStores"; +import separator from "~/menus/separator"; + +type Props = { + children: (props: any) => React.ReactNode; +}; + +function OrganizationMenu(props: Props) { + const [sessions] = useSessions(); + const menu = useMenuState({ + unstable_offset: [4, -4], + placement: "bottom-start", + modal: true, + }); + const { ui } = useStores(); + const { theme } = ui; + const team = useCurrentTeam(); + const previousTheme = usePrevious(theme); + const { t } = useTranslation(); + + React.useEffect(() => { + if (theme !== previousTheme) { + menu.hide(); + } + }, [menu, theme, previousTheme]); + + const actions = React.useMemo(() => { + const otherSessions = sessions.filter( + (session) => session.teamId !== team.id && session.url !== team.url + ); + + return [ + navigateToSettings, + separator(), + ...(otherSessions.length + ? [ + createAction({ + name: t("Switch team"), + section: "account", + children: otherSessions.map((session) => ({ + id: session.url, + name: session.name, + section: "account", + icon: , + perform: () => (window.location.href = session.url), + })), + }), + ] + : []), + logout, + ]; + }, [team.id, team.url, sessions, t]); + + return ( + <> + {props.children} + +