Compare commits

..

1 Commits

Author SHA1 Message Date
Saumya Pandey cf5e365c7a fix: indent the note 2022-02-22 00:17:04 +05:30
271 changed files with 3060 additions and 5232 deletions
+3 -4
View File
@@ -10,11 +10,11 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
REDIS_URL=redis://localhost:6379
REDIS_URL=redis://localhost:6479
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
@@ -36,7 +36,6 @@ COLLABORATION_URL=
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_ACCELERATE_URL=
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
+1 -2
View File
@@ -1,2 +1 @@
window.matchMedia = (data) => data;
window.env = {};
window.matchMedia = data => data;
+1 -6
View File
@@ -1,7 +1,6 @@
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import DynamicCollectionIcon from "~/components/CollectionIcon";
@@ -9,10 +8,6 @@ import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import history from "~/utils/history";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
return <DynamicCollectionIcon collection={collection} />;
};
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
section: CollectionSection,
@@ -25,7 +20,7 @@ export const openCollection = createAction({
// cache if the collection is renamed
id: collection.url,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
icon: <DynamicCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.url),
}));
+3 -24
View File
@@ -10,7 +10,6 @@ import {
ShapesIcon,
ImportIcon,
PinIcon,
SearchIcon,
} from "outline-icons";
import * as React from "react";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
@@ -18,7 +17,7 @@ import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -151,11 +150,10 @@ export const duplicateDocument = createAction({
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createAction({
export const pinDocument = createAction({
name: ({ t }) => t("Pin to collection"),
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId || !activeCollectionId) {
return false;
@@ -190,7 +188,6 @@ export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, currentTeamId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
@@ -217,13 +214,6 @@ export const pinDocumentToHome = createAction({
},
});
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
section: DocumentSection,
icon: <PinIcon />,
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
@@ -319,17 +309,6 @@ export const createTemplate = createAction({
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
section: DocumentSection,
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const rootDocumentActions = [
openDocument,
createDocument,
@@ -340,6 +319,6 @@ export const rootDocumentActions = [
unstarDocument,
duplicateDocument,
printDocument,
pinDocumentToCollection,
pinDocument,
pinDocumentToHome,
];
+15 -23
View File
@@ -10,7 +10,6 @@ import {
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
} from "outline-icons";
import * as React from "react";
import {
@@ -20,16 +19,14 @@ import {
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import { NavigationSection } from "~/actions/sections";
import history from "~/utils/history";
import {
organizationSettingsPath,
profileSettingsPath,
settingsPath,
homePath,
searchPath,
searchUrl,
draftsPath,
templatesPath,
archivePath,
@@ -45,13 +42,14 @@ export const navigateToHome = createAction({
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createAction({
section: RecentSearchesSection,
name: searchQuery.query,
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
section: NavigationSection,
shortcut: ["/"],
icon: <SearchIcon />,
perform: () => history.push(searchUrl()),
visible: ({ location }) => location.pathname !== searchUrl(),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
@@ -72,7 +70,6 @@ export const navigateToTemplates = createAction({
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
@@ -90,16 +87,9 @@ export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
perform: () => history.push(organizationSettingsPath()),
});
export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Profile"),
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
perform: () => history.push(profileSettingsPath()),
icon: <SettingsIcon />,
perform: () => history.push(settingsPath()),
});
export const openAPIDocumentation = createAction({
@@ -155,10 +145,12 @@ export const logout = createAction({
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
+15 -11
View File
@@ -1,6 +1,6 @@
import { flattenDeep } from "lodash";
import * as React from "react";
import { Optional } from "utility-types";
import { $Diff } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
Action,
@@ -10,10 +10,17 @@ import {
MenuItemWithChildren,
} from "~/types";
export function createAction(definition: Optional<Action, "id">): Action {
export function createAction(
definition: $Diff<
Action,
{
id?: string;
}
>
): Action {
return {
...definition,
id: uuidv4(),
...definition,
};
}
@@ -41,17 +48,14 @@ export function actionToMenuItem(
: undefined;
if (resolvedChildren) {
const items = resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter(Boolean)
.filter((a) => a.visible);
return {
type: "submenu",
title,
icon,
items,
visible: visible && items.length > 0,
items: resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter((a) => !!a),
visible,
};
}
@@ -98,7 +102,7 @@ export function actionToKBar(
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
keywords: `${action.keywords}`,
shortcut: action.shortcut || [],
icon: resolvedIcon,
perform: action.perform
-3
View File
@@ -11,6 +11,3 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
-73
View File
@@ -1,73 +0,0 @@
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
/**
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef(
(
{
action,
context,
tooltip,
hideOnActionDisabled,
...rest
}: Props & React.HTMLAttributes<HTMLButtonElement>,
ref: React.Ref<HTMLButtonElement>
) => {
const disabled = rest.disabled;
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
return null;
}
const label =
typeof action.name === "function" ? action.name(context) : action.name;
const button = (
<button
{...rest}
aria-label={label}
disabled={disabled}
ref={ref}
onClick={
action?.perform && context
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
action.perform?.(context);
}
: rest.onClick
}
>
{rest.children ?? label}
</button>
);
if (tooltip) {
return <Tooltip {...tooltip}>{button}</Tooltip>;
}
return button;
}
);
export default ActionButton;
-51
View File
@@ -1,51 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback(
(ev) => {
if (onEscape) {
if (ev.key === "Escape") {
onEscape(ev);
}
if (
ev.key === "ArrowUp" &&
composite.currentId === composite.items[0].id
) {
onEscape(ev);
}
}
},
[composite.currentId, composite.items, onEscape]
);
return (
<Composite
{...rest}
{...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
>
{children(composite)}
</Composite>
);
}
export default observer(React.forwardRef(ArrowKeyNavigation));
+6 -9
View File
@@ -11,12 +11,11 @@ import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import history from "~/utils/history";
import {
searchPath,
searchUrl,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import withStores from "./withStores";
const DocumentHistory = React.lazy(
@@ -50,7 +49,7 @@ class AuthenticatedLayout extends React.Component<Props> {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
history.push(searchPath());
history.push(searchUrl());
}
};
@@ -75,12 +74,10 @@ class AuthenticatedLayout extends React.Component<Props> {
}
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
) : undefined;
const rightRail = (
+3 -7
View File
@@ -11,7 +11,6 @@ type Props = {
icon?: React.ReactNode;
user?: User;
alt?: string;
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
};
@@ -30,13 +29,12 @@ class Avatar extends React.Component<Props> {
};
render() {
const { src, icon, showBorder, ...rest } = this.props;
const { src, icon, ...rest } = this.props;
return (
<AvatarWrapper>
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
$showBorder={showBorder}
{...rest}
/>
{icon && <IconWrapper>{icon}</IconWrapper>}
@@ -61,14 +59,12 @@ const IconWrapper = styled.div`
height: 20px;
`;
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
const CircleImg = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
border: 2px solid ${(props) => props.theme.background};
flex-shrink: 0;
`;
+38
View File
@@ -0,0 +1,38 @@
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>{count}</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;
+3 -6
View File
@@ -41,8 +41,7 @@ const RealButton = styled.button<{
border: 0;
}
&:hover:not(:disabled),
&[aria-expanded="true"] {
&:hover:not(:disabled) {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
@@ -77,8 +76,7 @@ const RealButton = styled.button<{
}
&:hover:not(:disabled),
&[aria-expanded="true"] {
&:hover:not(:disabled) {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
@@ -105,8 +103,7 @@ const RealButton = styled.button<{
background: ${props.theme.danger};
color: ${props.theme.white};
&:hover:not(:disabled),
&[aria-expanded="true"] {
&:hover:not(:disabled) {
background: ${darken(0.05, props.theme.danger)};
}
+1 -2
View File
@@ -13,8 +13,7 @@ const Container = styled.div<{ withStickyHeader?: boolean }>`
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props: any) =>
props.withStickyHeader ? "4px 60px 60px" : "60px"};
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
`};
`;
+2 -3
View File
@@ -10,7 +10,6 @@ import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -19,13 +18,13 @@ type Props = {
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { collections, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection.id);
const can = policies.abilities(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
+22 -52
View File
@@ -1,31 +1,25 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import { QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types";
import { metaDisplay } from "~/utils/keyboard";
import Text from "./Text";
export const CommandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
};
function CommandBar() {
const { t } = useTranslation();
const { ui } = useStores();
const settingsActions = useSettingsActions();
const commandBarActions = React.useMemo(
() => [...rootActions, settingsActions],
[settingsActions]
);
useCommandBarActions(commandBarActions);
useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.currentRootActionId
@@ -36,34 +30,20 @@ function CommandBar() {
}));
return (
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
{ui.commandBarOpenedFromSidebar && (
<Hint size="small" type="tertiary">
<QuestionMarkIcon size={18} color="currentColor" />
{t(
"Open search from anywhere with the {{ shortcut }} shortcut",
{
shortcut: `${metaDisplay} + k`,
}
)}
</Hint>
)}
</Animator>
</Positioner>
</KBarPortal>
</>
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
);
}
@@ -79,16 +59,6 @@ function KBarPortal({ children }: { children: React.ReactNode }) {
return <Portal>{children}</Portal>;
}
const Hint = styled(Text)`
display: flex;
align-items: center;
gap: 4px;
border-top: 1px solid ${(props) => props.theme.background};
margin: 1px 0 0;
padding: 6px 16px;
width: 100%;
`;
const Positioner = styled(KBarPositioner)`
z-index: ${(props) => props.theme.depths.commandBar};
`;
+3 -30
View File
@@ -1,7 +1,6 @@
import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
import useOnScreen from "~/hooks/useOnScreen";
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
disabled?: boolean;
@@ -70,17 +69,11 @@ const ContentEditable = React.forwardRef(
callback?.(event);
};
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(ref);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
React.useLayoutEffect(() => {
if (autoFocus) {
ref.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, ref]);
}, [autoFocus, ref]);
React.useEffect(() => {
if (value !== ref.current?.innerText) {
@@ -88,17 +81,6 @@ const ContentEditable = React.forwardRef(
}
}, [value, ref]);
// Ensure only plain text can be pasted into title when pasting from another
// rich text editor
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
@@ -107,7 +89,6 @@ const ContentEditable = React.forwardRef(
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
@@ -122,14 +103,6 @@ const ContentEditable = React.forwardRef(
);
const Content = styled.span`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
color: ${(props) => props.theme.text};
-webkit-text-fill-color: ${(props) => props.theme.text};
outline: none;
resize: none;
cursor: text;
&:empty {
display: inline-block;
}
+2 -8
View File
@@ -93,14 +93,11 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
type MenuAnchorProps = {
export const MenuAnchorCSS = css<{
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
}>`
display: flex;
margin: 0;
border: 0;
@@ -117,7 +114,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg:not(:last-child) {
margin-right: 4px;
@@ -149,8 +145,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`};
`;
+1 -1
View File
@@ -53,7 +53,7 @@ const Submenu = React.forwardRef(
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
<MenuAnchor {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
+1 -27
View File
@@ -1,14 +1,10 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
@@ -54,42 +50,20 @@ export default function ContextMenu({
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
rest,
t,
]);
}, [onOpen, onClose, previousVisible, rest.visible]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
@@ -1,7 +1,6 @@
import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Optional } from "utility-types";
import CollectionIcon from "~/components/CollectionIcon";
import Flex from "~/components/Flex";
import InputSelect from "~/components/InputSelect";
@@ -9,9 +8,7 @@ import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
type DefaultCollectionInputSelectProps = {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -19,7 +16,6 @@ type DefaultCollectionInputSelectProps = Optional<
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -92,11 +88,14 @@ const DefaultCollectionInputSelect = ({
return (
<InputSelect
value={defaultCollectionId ?? "home"}
label={t("Start view")}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
note={t(
"This is the screen that team members will first see when they sign in."
)}
short
{...rest}
/>
);
};
-1
View File
@@ -72,7 +72,6 @@ function DocumentHistory() {
</Header>
<Scrollable topShadow>
<PaginatedEventList
aria-label={t("History")}
fetch={events.fetchPage}
events={items}
options={{
+28
View File
@@ -0,0 +1,28 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
type Props = {
documents: Document[];
limit?: number;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
return (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.map((document) => (
<DocumentListItem key={document.id} document={document} {...rest} />
))}
</ArrowKeyNavigation>
);
}
+7 -23
View File
@@ -3,7 +3,6 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
@@ -13,13 +12,12 @@ import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -34,8 +32,7 @@ type Props = {
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
} & CompositeStateReturn;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
@@ -49,6 +46,7 @@ function DocumentListItem(
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -63,19 +61,17 @@ function DocumentListItem(
showTemplate,
highlight,
context,
...rest
} = props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(currentTeam.id);
const canCollection = usePolicy(document.collectionId);
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
return (
<CompositeItem
as={DocumentLink}
<DocumentLink
ref={ref}
dir={document.dir}
$isStarred={document.isStarred}
@@ -86,7 +82,6 @@ function DocumentListItem(
title: document.titleWithDefault,
},
}}
{...rest}
>
<Content>
<Heading dir={document.dir}>
@@ -160,7 +155,7 @@ function DocumentListItem(
modal={false}
/>
</Actions>
</CompositeItem>
</DocumentLink>
);
}
@@ -177,13 +172,6 @@ const Actions = styled(EventBoundary)`
flex-shrink: 0;
flex-grow: 0;
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
${breakpoint("tablet")`
display: flex;
`};
@@ -201,10 +189,6 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
+1 -1
View File
@@ -40,7 +40,6 @@ function DocumentViews({ document, isOpen }: Props) {
<>
{isOpen && (
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
@@ -62,6 +61,7 @@ function DocumentViews({ document, isOpen }: Props) {
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
compact
small
/>
);
+4 -9
View File
@@ -6,9 +6,9 @@ import ErrorBoundary from "~/components/ErrorBoundary";
import { Props as EditorProps } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useToasts from "~/hooks/useToasts";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { uploadFile } from "~/utils/uploadFile";
import { isHash } from "~/utils/urls";
const SharedEditor = React.lazy(
@@ -21,12 +21,7 @@ const SharedEditor = React.lazy(
export type Props = Optional<
EditorProps,
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "onShowToast"
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
@@ -40,7 +35,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
const { showToast } = useToasts();
const dictionary = useDictionary();
const onUploadFile = React.useCallback(
const onUploadImage = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -95,7 +90,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
<ErrorBoundary reloadOnChunkMissing>
<SharedEditor
ref={ref}
uploadFile={onUploadFile}
uploadImage={onUploadImage}
onShowToast={onShowToast}
embeds={embeds}
dictionary={dictionary}
-32
View File
@@ -1,32 +0,0 @@
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 (
<Span $size={size} {...rest}>
{emoji}
</Span>
);
}
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;
`;
+9 -44
View File
@@ -9,17 +9,13 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Avatar from "~/components/Avatar";
import CompositeItem, {
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
@@ -27,25 +23,19 @@ type Props = {
document: Document;
event: Event;
latest?: boolean;
} & CompositeStateReturn;
};
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const { policies } = useStores();
const location = useLocation();
const can = usePolicy(document.id);
const can = policies.abilities(document.id);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to;
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = React.useCallback(() => {
ref.current?.focus();
}, [ref]);
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
@@ -100,15 +90,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const isActive = location.pathname === to;
if (document.isDeleted) {
to = undefined;
}
return (
<BaseItem
<ListItem
small
exact
to={to}
to={document.isDeleted ? undefined : to}
title={
<Time
dateTime={event.createdAt}
@@ -116,7 +102,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
format="MMM do, h:mm a"
relative={false}
addSuffix
onClick={handleTimeClick}
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
@@ -131,22 +116,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
ref={ref}
{...rest}
/>
);
};
const BaseItem = React.forwardRef(
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
}
);
const Subtitle = styled.span`
svg {
margin: -3px;
@@ -154,7 +127,7 @@ const Subtitle = styled.span`
}
`;
const ItemStyle = css`
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
@@ -200,12 +173,4 @@ const ItemStyle = css`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default EventListItem;
+1 -1
View File
@@ -118,7 +118,7 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
min-height: 64px;
min-height: 56px;
justify-content: flex-start;
@supports (backdrop-filter: blur(20px)) {
+8
View File
@@ -5,6 +5,14 @@ const Heading = styled.h1<{ centered?: boolean }>`
align-items: center;
user-select: none;
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-top: 4px;
margin-left: -6px;
margin-right: 2px;
align-self: flex-start;
flex-shrink: 0;
}
`;
export default Heading;
+1 -1
View File
@@ -97,7 +97,7 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = React.HTMLAttributes<HTMLInputElement> & {
export type Props = {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
+2 -2
View File
@@ -7,7 +7,7 @@ import styled, { useTheme } from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers";
import { searchUrl } from "~/utils/routeHelpers";
import Input from "./Input";
type Props = {
@@ -51,7 +51,7 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath(ev.currentTarget.value, {
searchUrl(ev.currentTarget.value, {
collectionId,
ref: source,
})
+1 -5
View File
@@ -23,8 +23,6 @@ export type Option = {
};
export type Props = {
id?: string;
name?: string;
value?: string | null;
label?: string;
nude?: boolean;
@@ -56,7 +54,6 @@ const InputSelect = (props: Props) => {
disabled,
note,
icon,
...rest
} = props;
const select = useSelectState({
@@ -131,7 +128,7 @@ const InputSelect = (props: Props) => {
wrappedLabel
))}
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
<Select {...select} disabled={disabled} ref={buttonRef}>
{(props) => (
<StyledButton
neutral
@@ -232,7 +229,6 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
margin-bottom: 16px;
display: block;
width: 100%;
cursor: default;
&:hover:not(:disabled) {
background: ${(props) => props.theme.buttonNeutralBackground};
+1 -2
View File
@@ -8,7 +8,6 @@ import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
@@ -41,7 +40,7 @@ function Layout({ title, children, sidebar, rightRail }: Props) {
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
{sidebar}
<SkipNavContent />
<Content
-17
View File
@@ -1,17 +0,0 @@
import * as React from "react";
import {
CompositeStateReturn,
CompositeItem as BaseCompositeItem,
} from "reakit/Composite";
import Item, { Props as ItemProps } from "./Item";
export type Props = ItemProps & CompositeStateReturn;
function CompositeItem(
{ to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) {
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
}
export default React.forwardRef(CompositeItem);
+7 -9
View File
@@ -3,13 +3,14 @@ import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
export type Props = {
type Props = {
image?: React.ReactNode;
to?: string;
exact?: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
compact?: boolean;
border?: boolean;
small?: boolean;
};
@@ -49,7 +50,7 @@ const ListItem = (
<Wrapper
ref={ref}
$border={border}
$small={small}
$compact={compact}
activeStyle={{
background: theme.primary,
}}
@@ -63,17 +64,16 @@ const ListItem = (
}
return (
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
<Wrapper $compact={compact} $border={border} {...rest}>
{content(false)}
</Wrapper>
);
};
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) =>
props.$border === false ? (props.$small ? "8px 0" : "16px 0") : 0};
margin: ${(props) => (props.$compact === false ? 0 : "8px 0")};
padding: ${(props) => (props.$compact === false ? "8px 0" : 0)};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
@@ -81,8 +81,6 @@ const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
&:last-child {
border-bottom: 0;
}
cursor: ${({ to }) => (to ? "pointer" : "default")};
`;
const Image = styled(Flex)`
+5 -11
View File
@@ -1,18 +1,12 @@
import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
type Props = ActionButtonProps & {
const Button = styled.button.attrs((props) => ({
type: "type" in props ? props.type : "button",
}))<{
width?: number;
height?: number;
size?: number;
type?: "button" | "submit" | "reset";
};
const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
type: "type" in props ? props.type : "button",
}))<Props>`
}>`
width: ${(props) => props.width || props.size || 24}px;
height: ${(props) => props.height || props.size || 24}px;
background: none;
@@ -26,4 +20,4 @@ const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
color: inherit;
`;
export default StyledNudeButton;
export default Button;
+2 -17
View File
@@ -1,5 +1,4 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import PaginatedList from "~/components/PaginatedList";
@@ -23,37 +22,23 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
documents,
fetch,
options,
showParentDocuments,
showCollection,
showPublished,
showTemplate,
showDraft,
...rest
}: Props) {
const { t } = useTranslation();
return (
<PaginatedList
aria-label={t("Documents")}
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, _index, compositeProps) => (
renderItem={(item) => (
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
{...compositeProps}
{...rest}
/>
)}
{...rest}
/>
);
});
+9 -12
View File
@@ -29,19 +29,16 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index, compositeProps) => {
return (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
);
}}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
);
});
+39 -46
View File
@@ -1,13 +1,12 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores";
@@ -19,12 +18,9 @@ type Props = WithTranslation &
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
items: any[];
renderItem: (
item: any,
index: number,
composite: CompositeStateReturn
) => React.ReactNode;
renderItem: (arg0: any, index: number) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
};
@@ -133,47 +129,44 @@ class PaginatedList extends React.Component<Props> {
{showList && (
<>
{heading}
<ArrowKeyNavigation aria-label={this.props["aria-label"]}>
{(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(
item,
index,
composite
);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
})
}
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
-1
View File
@@ -49,7 +49,6 @@ function Scrollable(
React.useEffect(() => {
updateShadows();
}, [height, updateShadows]);
return (
<Wrapper
ref={ref || fallbackRef}
-27
View File
@@ -1,27 +0,0 @@
import { useKBar } from "kbar";
import * as React from "react";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
import { navigateToRecentSearchQuery } from "~/actions/definitions/navigation";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
searches.fetchPage({});
}, [searches]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
useCommandBarActions(
searchQuery ? [searchDocumentsForQuery(searchQuery)] : []
);
useCommandBarActions(searches.recent.map(navigateToRecentSearchQuery));
return null;
}
@@ -1,40 +1,46 @@
import { observer } from "mobx-react";
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
import {
EditIcon,
SearchIcon,
ShapesIcon,
HomeIcon,
SettingsIcon,
} 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 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 usePolicy from "~/hooks/usePolicy";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import AccountMenu from "~/menus/AccountMenu";
import {
homePath,
searchUrl,
draftsPath,
templatesPath,
searchPath,
settingsPath,
} from "~/utils/routeHelpers";
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 AppSidebar() {
function MainSidebar() {
const { t } = useTranslation();
const { documents } = useStores();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team.id);
const user = useCurrentUser();
React.useEffect(() => {
documents.fetchDrafts();
@@ -49,24 +55,24 @@ function AppSidebar() {
}),
[dndArea]
);
const can = policies.abilities(team.id);
return (
<Sidebar ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<OrganizationMenu>
<AccountMenu>
{(props) => (
<SidebarButton
<TeamButton
{...props}
title={team.name}
image={
<StyledTeamLogo src={team.avatarUrl} width={32} height={32} />
}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
/>
)}
</OrganizationMenu>
<Scrollable flex shadow>
</AccountMenu>
<Scrollable flex topShadow>
<Section>
<SidebarLink
to={homePath()}
@@ -75,7 +81,12 @@ function AppSidebar() {
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
to={{
pathname: searchUrl(),
state: {
fromMenu: true,
},
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
@@ -85,19 +96,15 @@ function AppSidebar() {
to={draftsPath()}
icon={<EditIcon color="currentColor" />}
label={
<Flex align="center" justify="space-between">
<Drafts align="center">
{t("Drafts")}
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts}
</Drafts>
</Flex>
<Bubble count={documents.totalDrafts} />
</Drafts>
}
/>
)}
</Section>
<Section>
<Starred />
</Section>
<Starred />
<Section auto>
<Collections />
</Section>
@@ -121,6 +128,12 @@ function AppSidebar() {
<TrashLink />
</>
)}
<SidebarLink
to={settingsPath()}
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
@@ -130,12 +143,8 @@ function AppSidebar() {
);
}
const StyledTeamLogo = styled(TeamLogo)`
margin-right: 4px;
const Drafts = styled(Flex)`
height: 24px;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
export default observer(MainSidebar);
+132 -26
View File
@@ -1,19 +1,34 @@
import { groupBy } from "lodash";
import { observer } from "mobx-react";
import { BackIcon } from "outline-icons";
import {
NewDocumentIcon,
EmailIcon,
ProfileIcon,
PadlockIcon,
CodeIcon,
UserIcon,
GroupIcon,
LinkIcon,
TeamIcon,
ExpandedIcon,
BeakerIcon,
DownloadIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import useCurrentTeam from "~/hooks/useCurrentTeam";
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";
@@ -21,38 +36,124 @@ const isHosted = env.DEPLOYMENT === "hosted";
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const configs = useAuthorizedSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const team = useCurrentTeam();
const { policies } = useStores();
const can = policies.abilities(team.id);
const returnToApp = React.useCallback(() => {
const returnToDashboard = React.useCallback(() => {
history.push("/home");
}, [history]);
return (
<Sidebar>
<SidebarButton
title={t("Return to App")}
image={<StyledBackIcon color="currentColor" />}
onClick={returnToApp}
minHeight={48}
<TeamButton
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={returnToDashboard}
/>
<Flex auto column>
<Scrollable shadow>
{Object.keys(groupedConfig).map((header) => (
<Section key={header}>
<Header>{header}</Header>
{groupedConfig[header].map((item) => (
<Scrollable topShadow>
<Section>
<Header>{t("Account")}</Header>
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
{can.createApiKey && (
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
)}
</Section>
<Section>
<Header>{t("Team")}</Header>
{can.update && (
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("Members")}
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
/>
{can.manage && (
<SidebarLink
to="/settings/import"
icon={<NewDocumentIcon color="currentColor" />}
label={t("Import")}
/>
)}
{can.export && (
<SidebarLink
to="/settings/export"
icon={<DownloadIcon color="currentColor" />}
label={t("Export")}
/>
)}
</Section>
{can.update && (env.SLACK_KEY || isHosted) && (
<Section>
<Header>{t("Integrations")}</Header>
{env.SLACK_KEY && (
<SidebarLink
key={item.path}
to={item.path}
icon={<item.icon color="currentColor" />}
label={item.name}
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
))}
)}
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
)}
</Section>
))}
{!isHosted && (
)}
{can.update && !isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
@@ -64,8 +165,13 @@ function SettingsSidebar() {
);
}
const StyledBackIcon = styled(BackIcon)`
margin-left: 4px;
const BackIcon = styled(ExpandedIcon)`
transform: rotate(90deg);
margin-left: -8px;
`;
const ReturnToApp = styled(Flex)`
height: 16px;
`;
export default observer(SettingsSidebar);
+1 -2
View File
@@ -14,7 +14,7 @@ type Props = {
};
function SharedSidebar({ rootNode, shareId }: Props) {
const { ui, documents } = useStores();
const { documents } = useStores();
return (
<Sidebar>
@@ -25,7 +25,6 @@ function SharedSidebar({ rootNode, shareId }: Props) {
shareId={shareId}
depth={1}
node={rootNode}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
</Section>
+28 -49
View File
@@ -5,18 +5,16 @@ 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 useMenuContext from "~/hooks/useMenuContext";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
import { fadeIn } from "~/styles/animations";
import Avatar from "../Avatar";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton from "./components/SidebarButton";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
const ANIMATION_MS = 250;
let isFirstRender = true;
type Props = {
children: React.ReactNode;
@@ -27,14 +25,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui, auth } = useStores();
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const { user } = auth;
const width = ui.sidebarWidth;
const collapsed = (ui.isEditing || ui.sidebarCollapsed) && !isMenuOpen;
const collapsed = ui.isEditing || ui.sidebarCollapsed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -131,6 +126,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
React.useEffect(() => {
if (location !== previousLocation) {
isFirstRender = false;
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
@@ -150,6 +146,28 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
[width, theme.sidebarCollapsedWidth, collapsed]
);
const content = (
<>
{ui.mobileSidebarVisible && (
<Portal>
<Backdrop onClick={ui.toggleMobileSidebar} />
</Portal>
)}
{children}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</>
);
return (
<>
<Container
@@ -161,42 +179,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
$collapsed={collapsed}
column
>
{ui.mobileSidebarVisible && (
<Portal>
<Backdrop onClick={ui.toggleMobileSidebar} />
</Portal>
)}
{children}
{user && (
<AccountMenu>
{(props) => (
<SidebarButton
{...props}
showMoreMenu
title={user.name}
image={
<StyledAvatar
src={user.avatarUrl}
size={24}
showBorder={false}
/>
}
/>
)}
</AccountMenu>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
{isFirstRender ? <Fade>{content}</Fade> : content}
</Container>
{!ui.isEditing && (
<Toggle
@@ -211,10 +194,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
}
);
const StyledAvatar = styled(Avatar)`
margin-left: 4px;
`;
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
@@ -1,8 +1,7 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
import { useDrop, useDrag } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom";
import styled from "styled-components";
@@ -11,15 +10,11 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import DocumentReparent from "~/scenes/DocumentReparent";
import CollectionIcon from "~/components/CollectionIcon";
import Fade from "~/components/Fade";
import Modal from "~/components/Modal";
import NudeButton from "~/components/NudeButton";
import { createDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import CollectionSortMenu from "~/menus/CollectionSortMenu";
import { NavigationNode } from "~/types";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
@@ -70,20 +65,13 @@ function CollectionLink({
setIsEditing(isEditing);
}, []);
const { ui, documents, collections } = useStores();
const { ui, documents, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId
);
const [openedOnce, setOpenedOnce] = React.useState(expanded);
React.useEffect(() => {
if (expanded) {
setOpenedOnce(true);
}
}, [expanded]);
const manualSort = collection.sort.field === "index";
const can = usePolicy(collection.id);
const can = policies.abilities(collection.id);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to re-parent document
@@ -117,7 +105,7 @@ function CollectionLink({
}
},
canDrop: () => {
return can.update;
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver({
@@ -130,7 +118,7 @@ function CollectionLink({
// Drop to reorder document
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
drop: async (item: DragObject) => {
if (!collection) {
return;
}
@@ -143,11 +131,11 @@ function CollectionLink({
// Drop to reorder collection
const [
{ isCollectionDropping, isDraggingAnyCollection },
{ isCollectionDropping, isDraggingAnotherCollection },
dropToReorderCollection,
] = useDrop({
accept: "collection",
drop: (item: DragObject) => {
drop: async (item: DragObject) => {
collections.move(
item.id,
fractionalIndex(collection.index, belowCollectionIndex)
@@ -159,9 +147,9 @@ function CollectionLink({
(!belowCollection || item.id !== belowCollection.id)
);
},
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
collect: (monitor) => ({
isCollectionDropping: monitor.isOver(),
isDraggingAnyCollection: monitor.getItemType() === "collection",
isDraggingAnotherCollection: monitor.canDrop(),
}),
});
@@ -206,7 +194,8 @@ function CollectionLink({
collection.sort,
]);
const displayDocumentLinks = expanded && !isCollectionDragging;
const isDraggingAnyCollection =
isDraggingAnotherCollection || isCollectionDragging;
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
@@ -215,18 +204,21 @@ function CollectionLink({
return;
}
if (collection.id === ui.activeCollectionId) {
setExpanded(true);
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [collection.id, ui.activeCollectionId, search]);
const context = useActionContext({
activeCollectionId: collection.id,
});
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
return (
<>
<Relative ref={drop}>
<div
ref={drop}
style={{
position: "relative",
}}
>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
@@ -236,16 +228,8 @@ function CollectionLink({
<DropToImport collectionId={collection.id}>
<SidebarLink
to={collection.url}
expanded={displayDocumentLinks}
onDisclosureClick={(event) => {
event.preventDefault();
setExpanded((prev) => !prev);
}}
icon={
<CollectionIcon
collection={collection}
expanded={displayDocumentLinks}
/>
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
@@ -258,55 +242,30 @@ function CollectionLink({
/>
}
exact={false}
depth={0}
depth={0.5}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ tooltip: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
!isEditing && (
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
)}
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
</>
)
}
/>
</DropToImport>
</Draggable>
</Relative>
<Relative>
{openedOnce && (
<Folder $open={displayDocumentLinks}>
{manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
{collectionDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
{isDraggingAnyCollection && (
<DropCursor
@@ -314,8 +273,21 @@ function CollectionLink({
innerRef={dropToReorderCollection}
/>
)}
</Relative>
</div>
{expanded &&
collectionDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
@@ -334,17 +306,13 @@ function CollectionLink({
);
}
const Relative = styled.div`
position: relative;
`;
const Folder = styled.div<{ $open?: boolean }>`
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);
@@ -1,5 +1,6 @@
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";
@@ -12,10 +13,9 @@ 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 { DragObject } from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
function Collections() {
const [isFetching, setFetching] = React.useState(false);
@@ -52,10 +52,7 @@ function Collections() {
load();
}, [collections, isFetching, showToast, fetchError, t]);
const [
{ isCollectionDropping, isDraggingAnyCollection },
dropToReorderCollection,
] = useDrop({
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
drop: async (item: DragObject) => {
collections.move(
@@ -68,19 +65,16 @@ function Collections() {
},
collect: (monitor) => ({
isCollectionDropping: monitor.isOver(),
isDraggingAnyCollection: monitor.getItemType() === "collection",
}),
});
const content = (
<>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
)}
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
{orderedCollections.map((collection: Collection, index: number) => (
<CollectionLink
key={collection.id}
@@ -91,14 +85,17 @@ function Collections() {
belowCollection={orderedCollections[index + 1]}
/>
))}
<SidebarAction action={createCollection} depth={0} />
<SidebarAction action={createCollection} depth={0.5} />
</>
);
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<SidebarLink
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
<PlaceholderCollections />
</Flex>
);
@@ -106,18 +103,19 @@ function Collections() {
return (
<Flex column>
<Header onClick={() => setExpanded((prev) => !prev)} expanded={expanded}>
{t("Collections")}
</Header>
{expanded && (
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
)}
<SidebarLink
onClick={() => setExpanded((prev) => !prev)}
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
</Flex>
);
}
const Relative = styled.div`
position: relative;
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Collections);
@@ -0,0 +1,12 @@
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;
@@ -1,54 +0,0 @@
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<HTMLButtonElement>;
expanded: boolean;
root?: boolean;
};
function Disclosure({ onClick, root, expanded, ...rest }: Props) {
return (
<Button size={20} onClick={onClick} $root={root} {...rest}>
<StyledCollapsedIcon expanded={expanded} size={20} color="currentColor" />
</Button>
);
}
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;
@@ -11,13 +11,12 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
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";
@@ -49,7 +48,6 @@ function DocumentLink(
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { showToast } = useToasts();
const { documents, policies } = useStores();
const { t } = useTranslation();
const isActiveDocument = activeDocument && activeDocument.id === node.id;
@@ -83,9 +81,7 @@ function DocumentLink(
isActiveDocument)
);
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
const [expanded, setExpanded] = React.useState(showChildren);
const [openedOnce, setOpenedOnce] = React.useState(expanded);
React.useEffect(() => {
if (showChildren) {
@@ -93,12 +89,6 @@ 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(() => {
@@ -108,7 +98,7 @@ function DocumentLink(
}, [expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev) => {
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
@@ -228,19 +218,6 @@ function DocumentLink(
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
if (!manualSort) {
showToast(
t(
"You can't reorder documents in an alphabetically sorted collection"
),
{
type: "info",
timeout: 5000,
}
);
return;
}
if (!collection) {
return;
}
@@ -293,8 +270,6 @@ function DocumentLink(
t("Untitled");
const can = policies.abilities(node.id);
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
return (
<>
@@ -308,8 +283,6 @@ function DocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
@@ -318,13 +291,21 @@ function DocumentLink(
},
}}
label={
<EditableTitle
title={title}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
isActive={(match, location) =>
!!match && location.search !== "?starred"
@@ -343,18 +324,16 @@ function DocumentLink(
!isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip tooltip={t("New doc")} delay={500}>
<NudeButton
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
>
<PlusIcon />
</NudeButton>
</Tooltip>
<NudeButton
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
>
<PlusIcon />
</NudeButton>
)}
<DocumentMenu
document={document}
@@ -368,40 +347,30 @@ function DocumentLink(
</DropToImport>
</div>
</Draggable>
{isDraggingAnyDocument && (
<DropCursor
disabled={!manualSort}
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
/>
{manualSort && isDraggingAnyDocument && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
{openedOnce && (
<Folder $open={expanded && !isDragging}>
{nodeChildren.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</Folder>
)}
{expanded &&
!isDragging &&
nodeChildren.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</>
);
}
const Folder = styled.div<{ $open?: boolean }>`
display: ${(props) => (props.$open ? "block" : "none")};
`;
const Relative = styled.div`
position: relative;
`;
@@ -1,30 +1,20 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
disabled?: boolean;
function DropCursor({
isActiveDrop,
innerRef,
position,
}: {
isActiveDrop: boolean;
innerRef: React.Ref<HTMLDivElement>;
position?: "top";
};
function DropCursor({ isActiveDrop, innerRef, position, disabled }: Props) {
return (
<Cursor
isOver={isActiveDrop}
disabled={disabled}
ref={innerRef}
position={position}
/>
);
}) {
return <Cursor isOver={isActiveDrop} ref={innerRef} position={position} />;
}
// transparent hover zone with a thin visible band vertically centered
const Cursor = styled.div<{
isOver?: boolean;
disabled?: boolean;
position?: "top";
}>`
const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
opacity: ${(props) => (props.isOver ? 1 : 0)};
transition: opacity 150ms;
position: absolute;
@@ -33,13 +23,10 @@ const Cursor = styled.div<{
width: 100%;
height: 14px;
background: transparent;
${(props) => (props.position === "top" ? "top: -7px;" : "bottom: -7px;")}
${(props) => (props.position === "top" ? "top: 25px;" : "bottom: -7px;")}
::after {
background: ${(props) =>
props.disabled
? props.theme.sidebarActiveBackground
: props.theme.slateDark};
background: ${(props) => props.theme.slateDark};
position: absolute;
top: 6px;
content: "";
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import LoadingIndicator from "~/components/LoadingIndicator";
import useImportDocument from "~/hooks/useImportDocument";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -20,7 +19,7 @@ type Props = {
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
@@ -29,7 +28,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const targetId = collectionId || documentId;
invariant(targetId, "Must provide either collectionId or documentId");
const can = usePolicy(targetId);
const can = policies.abilities(targetId);
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
@@ -0,0 +1,14 @@
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;
@@ -1,64 +0,0 @@
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 (
<H3>
<Button onClick={onClick} disabled={!onClick}>
{children}
{onClick && (
<Disclosure expanded={expanded} color="currentColor" size={20} />
)}
</Button>
</H3>
);
}
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;
@@ -13,7 +13,8 @@ function PlaceholderCollections() {
}
const Wrapper = styled.div`
margin: 4px 12px;
margin: 4px 16px;
margin-left: 40px;
width: 75%;
`;
@@ -11,8 +11,7 @@ import SidebarLink from "./SidebarLink";
type Props = {
node: NavigationNode;
collection?: Collection;
activeDocumentId: string | undefined;
activeDocument: Document | undefined;
activeDocument: Document | null | undefined;
isDraft?: boolean;
depth: number;
index: number;
@@ -21,21 +20,13 @@ type Props = {
};
function DocumentLink(
{
node,
collection,
activeDocument,
activeDocumentId,
isDraft,
depth,
shareId,
}: Props,
{ node, collection, activeDocument, isDraft, depth, shareId }: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { documents } = useStores();
const { t } = useTranslation();
const isActiveDocument = activeDocumentId === node.id;
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments =
!!node.children.length || activeDocument?.parentDocumentId === node.id;
@@ -121,7 +112,6 @@ function DocumentLink(
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
@@ -1,82 +0,0 @@
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<HTMLButtonElement>;
};
const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
(
{
showDisclosure,
showMoreMenu,
image,
title,
minHeight = 0,
...rest
}: Props,
ref
) => (
<Wrapper
justify="space-between"
align="center"
as="button"
minHeight={minHeight}
{...rest}
ref={ref}
>
<Title gap={4} align="center">
{image}
{title}
</Title>
{showDisclosure && <ExpandedIcon color="currentColor" />}
{showMoreMenu && <MoreIcon color="currentColor" />}
</Wrapper>
)
);
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,
&[aria-expanded="true"] {
color: ${(props) => props.theme.sidebarText};
transition: background 100ms ease-in-out;
background: ${(props) => props.theme.sidebarActiveBackground};
}
`;
export default SidebarButton;
@@ -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,14 +19,11 @@ type Props = Omit<NavLinkProps, "to"> & {
innerRef?: (arg0: HTMLElement | null | undefined) => void;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
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;
@@ -53,8 +50,6 @@ function SidebarLink(
href,
depth,
className,
expanded,
onDisclosureClick,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -71,10 +66,10 @@ function SidebarLink(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarActiveBackground,
background: theme.sidebarItemBackground,
...style,
}),
[theme.text, theme.sidebarActiveBackground, style]
[theme, style]
);
return (
@@ -95,34 +90,14 @@ function SidebarLink(
ref={ref}
{...rest}
>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onClick={onDisclosureClick}
root={depth === 0}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
</Content>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
</Link>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
);
}
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;
@@ -133,15 +108,12 @@ export const IconWrapper = styled.span`
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
display: inline-flex;
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
position: absolute;
top: 4px;
right: 4px;
gap: 4px;
color: ${(props) => props.theme.textTertiary};
transition: opacity 50ms;
height: 24px;
svg {
color: ${(props) => props.theme.textSecondary};
@@ -150,7 +122,7 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
}
&:hover {
visibility: visible;
display: inline-flex;
svg {
opacity: 0.75;
@@ -186,25 +158,29 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
transition: fill 50ms;
}
&:hover svg {
display: inline;
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
}
& + ${Actions} {
background: ${(props) => props.theme.sidebarBackground};
${NudeButton} {
background: transparent;
background: ${(props) => props.theme.sidebarBackground};
}
}
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
&:focus + ${Actions} {
${NudeButton} {
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
}
}
&[aria-current="page"] + ${Actions} {
background: ${(props) => props.theme.sidebarActiveBackground};
${NudeButton} {
background: ${(props) => props.theme.sidebarItemBackground};
}
}
${breakpoint("tablet")`
@@ -214,7 +190,7 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
@media (hover: hover) {
&:hover + ${Actions}, &:active + ${Actions} {
visibility: visible;
display: inline-flex;
svg {
opacity: 0.75;
@@ -226,12 +202,6 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
}
&:hover {
${Disclosure} {
opacity: 1;
}
}
`;
const Label = styled.div`
@@ -239,7 +209,6 @@ const Label = styled.div`
width: 100%;
max-height: 4.8em;
line-height: 1.6;
* {
unicode-bidi: plaintext;
}
+58 -50
View File
@@ -1,5 +1,6 @@
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";
@@ -9,8 +10,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";
@@ -118,64 +119,71 @@ function Starred() {
}),
});
const content = stars.orderedData.slice(0, upperBound).map((star) => {
const document = documents.get(star.documentId);
return document ? (
<StarredLink
key={star.id}
star={star}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
depth={2}
/>
) : null;
});
if (!stars.orderedData.length) {
return null;
}
return (
<Flex column>
<Header onClick={handleExpandClick} expanded={expanded}>
{t("Starred")}
</Header>
{expanded && (
<Relative>
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{stars.orderedData.slice(0, upperBound).map((star) => {
const document = documents.get(star.documentId);
return document ? (
<StarredLink
key={star.id}
star={star}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
depth={0}
<Section>
<Flex column>
<SidebarLink
onClick={handleExpandClick}
label={t("Starred")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (
<>
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{content}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={2}
/>
) : null;
})}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={0}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={0}
/>
)}
{(isFetching || fetchError) && !stars.orderedData.length && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</Relative>
)}
</Flex>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={2}
/>
)}
{(isFetching || fetchError) && !stars.orderedData.length && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</>
)}
</Flex>
</Section>
);
}
const Relative = styled.div`
position: relative;
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Starred);
@@ -1,18 +1,19 @@
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 styled, { useTheme } from "styled-components";
import parseTitle from "@shared/utils/parseTitle";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "@shared/constants";
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 = {
@@ -26,22 +27,24 @@ type Props = {
function StarredLink({
depth,
title,
to,
documentId,
title,
collectionId,
star,
}: Props) {
const theme = useTheme();
const { collections, documents } = useStores();
const { t } = useTranslation();
const { collections, documents, policies } = 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() {
@@ -54,7 +57,7 @@ function StarredLink({
}, [collection, collectionId, collections, document, documentId, documents]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
(ev: React.MouseEvent<SVGElement>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
@@ -62,6 +65,29 @@ 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",
@@ -70,7 +96,7 @@ function StarredLink({
isDragging: !!monitor.isDragging(),
}),
canDrag: () => {
return depth === 0;
return depth === 2;
},
});
@@ -90,34 +116,36 @@ function StarredLink({
}),
});
const { emoji } = parseTitle(title);
const label = emoji ? title.replace(emoji, "") : title;
return (
<>
<Draggable key={documentId} ref={drag} $isDragging={isDragging}>
<SidebarLink
depth={depth}
expanded={hasChildDocuments ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
to={`${to}?starred`}
icon={
depth === 0 ? (
emoji ? (
<EmojiIcon emoji={emoji} />
) : (
<StarredIcon color={theme.yellow} />
)
) : undefined
}
isActive={(match, location) =>
!!match && location.search === "?starred"
}
label={depth === 0 ? label : title}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title || t("Untitled")}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
exact={false}
showActions={menuOpen}
menu={
document ? (
document && !isEditing ? (
<Fade>
<DocumentMenu
document={document}
@@ -136,7 +164,7 @@ function StarredLink({
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth === 0 ? 2 : depth + 1}
depth={depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
@@ -0,0 +1,92 @@
import { observer } from "mobx-react";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
import TeamLogo from "~/components/TeamLogo";
type Props = {
teamName: string;
subheading: React.ReactNode;
showDisclosure?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
logoUrl: string;
};
const TeamButton = React.forwardRef<HTMLButtonElement, Props>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
<Wrapper>
<Header ref={ref} {...rest}>
<TeamLogo
alt={`${teamName} logo`}
src={logoUrl}
width={38}
height={38}
/>
<Flex align="flex-start" column>
<TeamName>
{teamName} {showDisclosure && <Disclosure color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
</Wrapper>
)
);
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);
+1 -1
View File
@@ -1,3 +1,3 @@
import Sidebar from "./App";
import Sidebar from "./Main";
export default Sidebar;
+18 -7
View File
@@ -1,9 +1,8 @@
import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Document from "~/models/Document";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
@@ -13,10 +12,22 @@ type Props = {
};
function Star({ size, document, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const context = useActionContext({
activeDocumentId: document.id,
});
const handleClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (document.isStarred) {
document.unstar();
} else {
document.star();
}
},
[document]
);
if (!document) {
return null;
@@ -24,9 +35,9 @@ function Star({ size, document, ...rest }: Props) {
return (
<NudeButton
context={context}
action={document.isStarred ? unstarDocument : starDocument}
onClick={handleClick}
size={size}
aria-label={document.isStarred ? t("Unstar") : t("Star")}
{...rest}
>
{document.isStarred ? (
+7 -3
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import styled from "styled-components";
import { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import Flex from "./Flex";
type Props = React.HTMLAttributes<HTMLInputElement> & {
width?: number;
@@ -49,9 +50,12 @@ function Switch({
<InlineLabelText>{label}</InlineLabelText>
</Label>
{note && (
<Text type="secondary" size="small">
{note}
</Text>
<Flex>
<Input width={width} height={height} aria-hidden="true" />
<Text type="secondary" size="small">
{note}
</Text>
</Flex>
)}
</Wrapper>
);
+15 -43
View File
@@ -6,10 +6,8 @@ import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import styled from "styled-components";
import Button from "~/components/Button";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
export type Props = {
@@ -123,11 +121,7 @@ function Table({
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => (
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
<SortWrapper align="center" gap={4}>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
@@ -196,19 +190,17 @@ export const Placeholder = ({
rows?: number;
}) => {
return (
<DelayedMount>
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
</DelayedMount>
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
);
};
@@ -222,8 +214,6 @@ const Pagination = styled(Flex)`
`;
const DescSortIcon = styled(CollapsedIcon)`
margin-left: -2px;
&:hover {
fill: ${(props) => props.theme.text};
}
@@ -239,23 +229,12 @@ const InnerTable = styled.table`
width: 100%;
`;
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
display: inline-flex;
const SortWrapper = styled(Flex)`
height: 24px;
user-select: none;
border-radius: 4px;
white-space: nowrap;
margin: 0 -4px;
padding: 0 4px;
&:hover {
background: ${(props) =>
props.$sortable ? props.theme.secondaryBackground : "none"};
}
`;
const Cell = styled.td`
padding: 10px 6px;
padding: 6px;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
@@ -269,13 +248,6 @@ const Cell = styled.td`
text-align: right;
vertical-align: bottom;
}
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
`;
const Row = styled.tr`
@@ -298,7 +270,7 @@ const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px 6px 0;
padding: 6px;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
-75
View File
@@ -1,75 +0,0 @@
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import useQuery from "~/hooks/useQuery";
import type { Props } from "./Table";
const Table = React.lazy(
() =>
import(
/* webpackChunkName: "table" */
"~/components/Table"
)
);
const TableFromParams = (
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
) => {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
params.set("direction", direction.toLowerCase());
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "start",
});
}
},
[params, history, location.pathname]
);
return (
<Table
topRef={topRef}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
{...props}
/>
);
};
export default TableFromParams;
+1 -1
View File
@@ -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: white;
background: ${(props) => props.theme.background};
border: 1px solid ${(props) => props.theme.divider};
overflow: hidden;
flex-shrink: 0;
+2 -4
View File
@@ -2,7 +2,7 @@ import styled from "styled-components";
type Props = {
type?: "secondary" | "tertiary";
size?: "large" | "small" | "xsmall";
size?: "small" | "xsmall";
};
/**
@@ -18,9 +18,7 @@ const Text = styled.p<Props>`
? props.theme.textTertiary
: props.theme.text};
font-size: ${(props) =>
props.size === "large"
? "18px"
: props.size === "small"
props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
+9 -13
View File
@@ -9,11 +9,9 @@ const LocaleTime = React.lazy(
)
);
type Props = React.ComponentProps<typeof LocaleTime> & {
onClick?: () => void;
};
type Props = React.ComponentProps<typeof LocaleTime>;
function Time({ onClick, ...props }: Props) {
function Time(props: Props) {
let content = formatDistanceToNow(Date.parse(props.dateTime), {
addSuffix: props.addSuffix,
});
@@ -26,15 +24,13 @@ function Time({ onClick, ...props }: Props) {
}
return (
<span onClick={onClick}>
<React.Suspense
fallback={
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime tooltipDelay={250} {...props} />
</React.Suspense>
</span>
<React.Suspense
fallback={
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime tooltipDelay={250} {...props} />
</React.Suspense>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { TFunctionResult } from "i18next";
import * as React from "react";
import styled from "styled-components";
export type Props = Omit<TippyProps, "content" | "theme"> & {
type Props = Omit<TippyProps, "content" | "theme"> & {
tooltip: React.ReactChild | React.ReactChild[] | TFunctionResult;
shortcut?: React.ReactNode;
};
+22 -27
View File
@@ -27,10 +27,10 @@ export type Props<T extends MenuItem = MenuItem> = {
dictionary: Dictionary;
view: EditorView;
search: string;
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast: (message: string, id: string) => void;
uploadImage?: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, id: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
onClearSearch: () => void;
@@ -178,9 +178,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerFilePick("image/*");
case "attachment":
return this.triggerFilePick("*");
return this.triggerImagePick();
case "embed":
return this.triggerLinkInput(item);
case "link": {
@@ -214,7 +212,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const href = event.currentTarget.value;
const matches = this.state.insertItem.matcher(href);
if (!matches) {
if (!matches && this.props.onShowToast) {
this.props.onShowToast(
this.props.dictionary.embedInvalidLink,
ToastType.Error
@@ -260,11 +258,8 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
};
triggerFilePick = (accept: string) => {
triggerImagePick = () => {
if (this.inputRef.current) {
if (accept) {
this.inputRef.current.accept = accept;
}
this.inputRef.current.click();
}
};
@@ -273,14 +268,14 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
this.setState({ insertItem: item });
};
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
handleImagePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(event);
const {
view,
uploadFile,
onFileUploadStart,
onFileUploadStop,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = this.props;
const { state } = view;
@@ -288,18 +283,17 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
this.clearSearch();
if (!uploadFile) {
throw new Error("uploadFile prop is required to replace files");
if (!uploadImage) {
throw new Error("uploadImage prop is required to replace images");
}
if (parent) {
insertFiles(view, event, parent.pos, files, {
uploadFile,
onFileUploadStart,
onFileUploadStop,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
dictionary: this.props.dictionary,
isAttachment: this.inputRef.current?.accept === "*",
});
}
@@ -415,7 +409,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const {
embeds = [],
search = "",
uploadFile,
uploadImage,
commands,
filterable = true,
} = this.props;
@@ -453,7 +447,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
// If no image upload callback has been passed, filter the image block out
if (!uploadFile && item.name === "image") {
if (!uploadImage && item.name === "image") {
return false;
}
@@ -476,7 +470,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
render() {
const { dictionary, isActive, uploadFile } = this.props;
const { dictionary, isActive, uploadImage } = this.props;
const items = this.filtered;
const { insertItem, ...positioning } = this.state;
@@ -543,12 +537,13 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
)}
</List>
)}
{uploadFile && (
{uploadImage && (
<VisuallyHidden>
<input
type="file"
ref={this.inputRef}
onChange={this.handleFilePicked}
onChange={this.handleImagePicked}
accept="image/*"
/>
</VisuallyHidden>
)}
+1 -1
View File
@@ -44,7 +44,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast: (message: string, code: string) => void;
onShowToast?: (message: string, code: string) => void;
view: EditorView;
};
+1 -1
View File
@@ -15,7 +15,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast: (msg: string, code: string) => void;
onShowToast?: (msg: string, code: string) => void;
onClose: () => void;
};
+1 -1
View File
@@ -36,7 +36,7 @@ type Props = {
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
onShowToast: (msg: string, code: string) => void;
onShowToast?: (msg: string, code: string) => void;
view: EditorView;
};
+13 -20
View File
@@ -28,7 +28,6 @@ import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Attachment from "@shared/editor/nodes/Attachment";
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
@@ -108,7 +107,7 @@ export type Props = {
/** Heading id to scroll to when the editor has loaded */
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadFile?: (file: File) => Promise<string>;
uploadImage?: (file: File) => Promise<string>;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
@@ -120,9 +119,9 @@ export type Props = {
/** Callback when user changes editor content */
onChange?: (value: () => string) => void;
/** Callback when a file upload begins */
onFileUploadStart?: () => void;
onImageUploadStart?: () => void;
/** Callback when a file upload ends */
onFileUploadStop?: () => void;
onImageUploadStop?: () => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (title: string) => Promise<string>;
/** Callback when user searches for documents from link insert interface */
@@ -143,7 +142,7 @@ export type Props = {
/** Whether embeds should be rendered without an iframe */
embedsDisabled?: boolean;
/** Callback when a toast message is triggered (eg "link copied") */
onShowToast: (message: string, code: ToastType) => void;
onShowToast?: (message: string, code: ToastType) => void;
className?: string;
style?: React.CSSProperties;
};
@@ -178,10 +177,10 @@ export class Editor extends React.PureComponent<
defaultValue: "",
dir: "auto",
placeholder: "Write something nice…",
onFileUploadStart: () => {
onImageUploadStart: () => {
// no default behavior
},
onFileUploadStop: () => {
onImageUploadStop: () => {
// no default behavior
},
embeds: [],
@@ -319,8 +318,7 @@ export class Editor extends React.PureComponent<
createExtensions() {
const { dictionary } = this.props;
// adding nodes here? Update server/editor/renderToHtml.ts for serialization
// on the server
// adding nodes here? Update schema.ts for serialization on the server
return new ExtensionManager(
[
...[
@@ -343,9 +341,6 @@ export class Editor extends React.PureComponent<
new BulletList(),
new Embed({ embeds: this.props.embeds }),
new ListItem(),
new Attachment({
dictionary,
}),
new Notice({
dictionary,
}),
@@ -356,9 +351,9 @@ export class Editor extends React.PureComponent<
new HorizontalRule(),
new Image({
dictionary,
uploadFile: this.props.uploadFile,
onFileUploadStart: this.props.onFileUploadStart,
onFileUploadStop: this.props.onFileUploadStop,
uploadImage: this.props.uploadImage,
onImageUploadStart: this.props.onImageUploadStart,
onImageUploadStop: this.props.onImageUploadStop,
onShowToast: this.props.onShowToast,
}),
new Table(),
@@ -784,7 +779,6 @@ export class Editor extends React.PureComponent<
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
onShowToast={this.props.onShowToast}
/>
<LinkToolbar
view={this.view}
@@ -801,7 +795,6 @@ export class Editor extends React.PureComponent<
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={() => this.setState({ emojiMenuOpen: false })}
@@ -814,10 +807,10 @@ export class Editor extends React.PureComponent<
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseBlockMenu}
uploadFile={this.props.uploadFile}
uploadImage={this.props.uploadImage}
onLinkToolbarOpen={this.handleOpenLinkMenu}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
onImageUploadStart={this.props.onImageUploadStart}
onImageUploadStop={this.props.onImageUploadStop}
onShowToast={this.props.onShowToast}
embeds={this.props.embeds}
/>
+3 -10
View File
@@ -15,7 +15,6 @@ import {
WarningIcon,
InfoIcon,
LinkIcon,
AttachmentIcon,
} from "outline-icons";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -85,12 +84,6 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
shortcut: `${metaDisplay} k`,
keywords: "link url uri href",
},
{
name: "attachment",
title: dictionary.file,
icon: AttachmentIcon,
keywords: "file upload attach",
},
{
name: "table",
title: dictionary.table,
@@ -131,21 +124,21 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
name: "container_notice",
title: dictionary.infoNotice,
icon: InfoIcon,
keywords: "notice card information",
keywords: "container_notice card information",
attrs: { style: "info" },
},
{
name: "container_notice",
title: dictionary.warningNotice,
icon: WarningIcon,
keywords: "notice card error",
keywords: "container_notice card error",
attrs: { style: "warning" },
},
{
name: "container_notice",
title: dictionary.tipNotice,
icon: StarredIcon,
keywords: "notice card suggestion",
keywords: "container_notice card suggestion",
attrs: { style: "tip" },
},
];
-1
View File
@@ -20,7 +20,6 @@ export default function useActionContext(
return {
isContextMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
currentUserId: stores.auth.user?.id,
-196
View File
@@ -1,196 +0,0 @@
import {
NewDocumentIcon,
EmailIcon,
ProfileIcon,
PadlockIcon,
CodeIcon,
UserIcon,
GroupIcon,
LinkIcon,
TeamIcon,
BeakerIcon,
DownloadIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications";
import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
import Zapier from "~/scenes/Settings/Zapier";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
type SettingsGroups = "Account" | "Team" | "Integrations";
type SettingsPage =
| "Profile"
| "Notifications"
| "Api"
| "Details"
| "Security"
| "Features"
| "Members"
| "Groups"
| "Shares"
| "Import"
| "Export"
| "Slack"
| "Zapier";
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<any>;
component: () => JSX.Element;
enabled: boolean;
group: SettingsGroups;
};
type ConfigType = {
[key in SettingsPage]: ConfigItem;
};
const isHosted = env.DEPLOYMENT === "hosted";
const useAuthorizedSettingsConfig = () => {
const team = useCurrentTeam();
const can = usePolicy(team.id);
const { t } = useTranslation();
const config: ConfigType = React.useMemo(
() => ({
Profile: {
name: t("Profile"),
path: "/settings",
component: Profile,
enabled: true,
group: t("Account"),
icon: ProfileIcon,
},
Notifications: {
name: t("Notifications"),
path: "/settings/notifications",
component: Notifications,
enabled: true,
group: t("Account"),
icon: EmailIcon,
},
Api: {
name: t("API Tokens"),
path: "/settings/tokens",
component: Tokens,
enabled: can.createApiKey,
group: t("Account"),
icon: CodeIcon,
},
// Team group
Details: {
name: t("Details"),
path: "/settings/details",
component: Details,
enabled: can.update,
group: t("Team"),
icon: TeamIcon,
},
Security: {
name: t("Security"),
path: "/settings/security",
component: Security,
enabled: can.update,
group: t("Team"),
icon: PadlockIcon,
},
Features: {
name: t("Features"),
path: "/settings/features",
component: Features,
enabled: can.update,
group: t("Team"),
icon: BeakerIcon,
},
Members: {
name: t("Members"),
path: "/settings/members",
component: Members,
enabled: true,
group: t("Team"),
icon: UserIcon,
},
Groups: {
name: t("Groups"),
path: "/settings/groups",
component: Groups,
enabled: true,
group: t("Team"),
icon: GroupIcon,
},
Shares: {
name: t("Share Links"),
path: "/settings/shares",
component: Shares,
enabled: true,
group: t("Team"),
icon: LinkIcon,
},
Import: {
name: t("Import"),
path: "/settings/import",
component: Import,
enabled: can.manage,
group: t("Team"),
icon: NewDocumentIcon,
},
Export: {
name: t("Export"),
path: "/settings/export",
component: Export,
enabled: can.export,
group: t("Team"),
icon: DownloadIcon,
},
// Intergrations
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
component: Slack,
enabled: can.update && (!!env.SLACK_KEY || isHosted),
group: t("Integrations"),
icon: SlackIcon,
},
Zapier: {
name: "Zapier",
path: "/settings/integrations/zapier",
component: Zapier,
enabled: can.update && isHosted,
group: t("Integrations"),
icon: ZapierIcon,
},
}),
[can.createApiKey, can.export, can.manage, can.update, t]
);
const enabledConfigs = React.useMemo(
() =>
Object.keys(config).reduce(
(acc, key: SettingsPage) =>
config[key].enabled ? [...acc, config[key]] : acc,
[]
),
[config]
);
return enabledConfigs;
};
export default useAuthorizedSettingsConfig;
+1 -5
View File
@@ -11,10 +11,7 @@ import useActionContext from "./useActionContext";
*
* @param actions actions to make available
*/
export default function useCommandBarActions(
actions: Action[],
additionalDeps: React.DependencyList = []
) {
export default function useCommandBarActions(actions: Action[]) {
const location = useLocation();
const context = useActionContext({
isCommandBar: true,
@@ -27,6 +24,5 @@ export default function useCommandBarActions(
useRegisterActions(registerable, [
registerable.map((r) => r.id).join(""),
location.pathname,
...additionalDeps,
]);
}
+1 -2
View File
@@ -32,7 +32,6 @@ export default function useDictionary() {
alignImageDefault: t("Center large"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
file: t("File attachment"),
findOrCreateDoc: `${t("Find or create a doc")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
@@ -40,7 +39,7 @@ export default function useDictionary() {
heading: t("Heading"),
hr: t("Divider"),
image: t("Image"),
fileUploadError: t("Sorry, an error occurred uploading the file"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
imageCaptionPlaceholder: t("Write a caption"),
info: t("Info"),
infoNotice: t("Info notice"),
-33
View File
@@ -1,33 +0,0 @@
import * as React from "react";
type Options = {
fontSize?: string;
lineHeight?: string;
};
/**
* Measures the width of an emoji character
*/
export default function useEmojiWidth(
emoji: string | undefined,
{ fontSize = "2.25em", lineHeight = "1.25" }: Options
) {
return React.useMemo(() => {
const element = window.document.createElement("span");
if (!emoji) {
return 0;
}
element.innerText = `${emoji}\u00A0`;
element.style.visibility = "hidden";
element.style.position = "absolute";
element.style.left = "-9999px";
element.style.lineHeight = lineHeight;
element.style.fontSize = fontSize;
element.style.width = "max-content";
window.document.body?.appendChild(element);
const width = window.getComputedStyle(element).width;
window.document.body?.removeChild(element);
return parseInt(width, 10);
}, [emoji, fontSize, lineHeight]);
}
-31
View File
@@ -1,31 +0,0 @@
import { noop } from "lodash";
import React from "react";
type MenuContextType = {
isMenuOpen: boolean;
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const MenuContext = React.createContext<MenuContextType | null>(null);
export const MenuProvider: React.FC = ({ children }) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const memoized = React.useMemo(
() => ({
isMenuOpen,
setIsMenuOpen,
}),
[isMenuOpen, setIsMenuOpen]
);
return (
<MenuContext.Provider value={memoized}>{children}</MenuContext.Provider>
);
};
const useMenuContext: () => MenuContextType = () => {
const value = React.useContext(MenuContext);
return value ? value : { isMenuOpen: false, setIsMenuOpen: noop };
};
export default useMenuContext;
-34
View File
@@ -1,34 +0,0 @@
import * as React from "react";
/**
* Hook to return if a given ref is visible on screen.
*
* @returns boolean if the node is visible
*/
export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
const isSupported = "IntersectionObserver" in window;
const [isIntersecting, setIntersecting] = React.useState(!isSupported);
React.useEffect(() => {
const element = ref.current;
let observer: IntersectionObserver | undefined;
if (isSupported) {
observer = new IntersectionObserver(([entry]) => {
// Update our state when observer callback fires
setIntersecting(entry.isIntersecting);
});
}
if (element) {
observer?.observe(element);
}
return () => {
if (element) {
observer?.unobserve(element);
}
};
}, []);
return isIntersecting;
}
+1 -1
View File
@@ -1,10 +1,10 @@
import * as React from "react";
/**
* Hook to return page visibility state.
*
* @returns boolean if the page is visible
*/
export default function usePageVisibility(): boolean {
const [visible, setVisible] = React.useState(true);
-12
View File
@@ -1,12 +0,0 @@
import useStores from "./useStores";
/**
* Quick access to retrieve the abilities of a policy for a given entity
*
* @param entityId The entity id
* @returns The available abilities
*/
export default function usePolicy(entityId: string) {
const { policies } = useStores();
return policies.abilities(entityId);
}
-38
View File
@@ -1,38 +0,0 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import history from "~/utils/history";
import useAuthorizedSettingsConfig from "./useAuthorizedSettingsConfig";
const useSettingsActions = () => {
const config = useAuthorizedSettingsConfig();
const actions = React.useMemo(() => {
return config.map((item) => {
const Icon = item.icon;
return {
id: item.path,
name: item.name,
icon: <Icon color="currentColor" />,
section: NavigationSection,
perform: () => history.push(item.path),
};
});
}, [config]);
const navigateToSettings = React.useMemo(
() =>
createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
children: () => actions,
}),
[actions]
);
return navigateToSettings;
};
export default useSettingsActions;
+2 -11
View File
@@ -8,6 +8,7 @@ import { Router } from "react-router-dom";
import { initI18n } from "@shared/i18n";
import stores from "~/stores";
import Analytics from "~/components/Analytics";
import { CommandBarOptions } from "~/components/CommandBar";
import Dialogs from "~/components/Dialogs";
import ErrorBoundary from "~/components/ErrorBoundary";
import PageTheme from "~/components/PageTheme";
@@ -52,16 +53,6 @@ if ("serviceWorker" in window.navigator) {
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
const commandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
callbacks: {
onClose: () => stores.ui.commandBarClosed(),
},
};
if (element) {
const App = () => (
<React.StrictMode>
@@ -69,7 +60,7 @@ if (element) {
<Analytics>
<Theme>
<ErrorBoundary>
<KBarProvider actions={[]} options={commandBarOptions}>
<KBarProvider actions={[]} options={CommandBarOptions}>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
+38 -4
View File
@@ -2,10 +2,13 @@ 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 {
navigateToProfileSettings,
navigateToSettings,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
@@ -14,7 +17,9 @@ 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";
@@ -23,12 +28,15 @@ type Props = {
};
function AccountMenu(props: Props) {
const [sessions] = useSessions();
const menu = useMenuState({
placement: "bottom-end",
unstable_offset: [8, 0],
placement: "bottom-start",
modal: true,
});
const { ui } = useStores();
const { theme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
@@ -39,19 +47,39 @@ 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: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
})),
}),
]
: []),
logout,
];
}, []);
}, [team.id, team.url, sessions, t]);
return (
<>
@@ -63,4 +91,10 @@ function AccountMenu(props: Props) {
);
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export default observer(AccountMenu);
+3 -46
View File
@@ -6,8 +6,6 @@ import {
ImportIcon,
ExportIcon,
PadlockIcon,
AlphabeticalSortIcon,
ManualSortIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -25,7 +23,6 @@ import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import Modal from "~/components/Modal";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
@@ -54,7 +51,7 @@ function CollectionMenu({
});
const [renderModals, setRenderModals] = React.useState(false);
const team = useCurrentTeam();
const { documents } = useStores();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const history = useHistory();
@@ -126,22 +123,8 @@ function CollectionMenu({
[history, showToast, collection.id, documents]
);
const handleChangeSort = React.useCallback(
(field: string) => {
menu.hide();
return collection.save({
sort: {
field,
direction: "asc",
},
});
},
[collection, menu]
);
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection.id);
const canUserInTeam = usePolicy(team.id);
const can = policies.abilities(collection.id);
const canUserInTeam = policies.abilities(team.id);
const items: MenuItem[] = React.useMemo(
() => [
{
@@ -161,30 +144,6 @@ function CollectionMenu({
{
type: "separator",
},
{
type: "submenu",
title: t("Sort in sidebar"),
visible: can.update,
icon: alphabeticalSort ? (
<AlphabeticalSortIcon color="currentColor" />
) : (
<ManualSortIcon color="currentColor" />
),
items: [
{
type: "button",
title: t("Alphabetical sort"),
onClick: () => handleChangeSort("title"),
selected: alphabeticalSort,
},
{
type: "button",
title: t("Manual sort"),
onClick: () => handleChangeSort("index"),
selected: !alphabeticalSort,
},
],
},
{
type: "button",
title: `${t("Edit")}`,
@@ -222,8 +181,6 @@ function CollectionMenu({
t,
can.update,
can.delete,
alphabeticalSort,
handleChangeSort,
handleNewDocument,
handleImportDocument,
collection,
+73
View File
@@ -0,0 +1,73 @@
import { observer } from "mobx-react";
import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import Collection from "~/models/Collection";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import NudeButton from "~/components/NudeButton";
type Props = {
collection: Collection;
onOpen?: () => void;
onClose?: () => void;
};
function CollectionSortMenu({ collection, onOpen, onClose }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const handleChangeSort = React.useCallback(
(field: string) => {
menu.hide();
return collection.save({
sort: {
field,
direction: "asc",
},
});
},
[collection, menu]
);
const alphabeticalSort = collection.sort.field === "title";
return (
<>
<MenuButton {...menu}>
{(props) => (
<NudeButton aria-label={t("Show sort menu")} {...props}>
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
</NudeButton>
)}
</MenuButton>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Sort in sidebar")}
>
<Template
{...menu}
items={[
{
type: "button",
title: t("Alphabetical sort"),
onClick: () => handleChangeSort("title"),
selected: alphabeticalSort,
},
{
type: "button",
title: t("Manual sort"),
onClick: () => handleChangeSort("index"),
selected: !alphabeticalSort,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(CollectionSortMenu);
+6 -3
View File
@@ -40,10 +40,12 @@ import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
import { pinDocument } from "~/actions/definitions/documents";
import {
pinDocument,
pinDocumentToHome,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
@@ -175,7 +177,7 @@ function DocumentMenu({
);
const collection = collections.get(document.collectionId);
const can = usePolicy(document.id);
const can = policies.abilities(document.id);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
() => [
@@ -326,6 +328,7 @@ function DocumentMenu({
visible: !document.isStarred && !!can.star,
icon: <StarredIcon />,
},
actionToMenuItem(pinDocumentToHome, context),
actionToMenuItem(pinDocument, context),
{
type: "separator",
+3 -2
View File
@@ -10,7 +10,7 @@ import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import Modal from "~/components/Modal";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
type Props = {
group: Group;
@@ -19,12 +19,13 @@ type Props = {
function GroupMenu({ group, onMembers }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const menu = useMenuState({
modal: true,
});
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = usePolicy(group.id);
const can = policies.abilities(group.id);
return (
<>
+2 -8
View File
@@ -4,22 +4,16 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import CollectionIcon from "~/components/CollectionIcon";
import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
return <CollectionIcon collection={collection} />;
};
function NewDocumentMenu() {
const menu = useMenuState({
modal: true,
@@ -27,7 +21,7 @@ function NewDocumentMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team.id);
const can = policies.abilities(team.id);
const items = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
@@ -38,7 +32,7 @@ function NewDocumentMenu() {
type: "route",
to: newDocumentPath(collection.id),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <ColorCollectionIcon collection={collection} />,
icon: <CollectionIcon collection={collection} />,
});
}
+1 -2
View File
@@ -10,7 +10,6 @@ import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -22,7 +21,7 @@ function NewTemplateMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team.id);
const can = policies.abilities(team.id);
const items = React.useMemo(
() =>
-82
View File
@@ -1,82 +0,0 @@
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: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
})),
}),
]
: []),
logout,
];
}, [team.id, team.url, sessions, t]);
return (
<>
<MenuButton {...menu}>{props.children}</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<Template {...menu} items={undefined} actions={actions} />
</ContextMenu>
</>
);
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export default observer(OrganizationMenu);
+2 -3
View File
@@ -9,7 +9,6 @@ import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "~/components/CopyToClipboard";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -21,11 +20,11 @@ function ShareMenu({ share }: Props) {
const menu = useMenuState({
modal: true,
});
const { shares } = useStores();
const { shares, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const history = useHistory();
const can = usePolicy(share.id);
const can = policies.abilities(share.id);
const handleGoToDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
+2 -3
View File
@@ -6,7 +6,6 @@ import User from "~/models/User";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
type Props = {
@@ -14,12 +13,12 @@ type Props = {
};
function UserMenu({ user }: Props) {
const { users } = useStores();
const { users, policies } = useStores();
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const can = usePolicy(user.id);
const can = policies.abilities(user.id);
const handlePromote = React.useCallback(
(ev: React.SyntheticEvent) => {
+7 -2
View File
@@ -1,5 +1,4 @@
import { computed } from "mobx";
import { bytesToHumanReadable } from "@shared/utils/files";
import BaseModal from "./BaseModel";
import User from "./User";
@@ -24,7 +23,13 @@ class FileOperation extends BaseModal {
@computed
get sizeInMB(): string {
return bytesToHumanReadable(this.size);
const inKB = this.size / 1024;
if (inKB < 1024) {
return inKB.toFixed(2) + "KB";
}
return (inKB / 1024).toFixed(2) + "MB";
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "@getoutline/y-prosemirror";
import { keymap } from "prosemirror-keymap";
} from "y-prosemirror";
import * as Y from "yjs";
import { Extension } from "~/editor";

Some files were not shown because too many files have changed in this diff Show More