feat: Command Bar (#2669)

This commit is contained in:
Tom Moor 2021-10-24 12:30:27 -07:00 committed by GitHub
parent dc92e1ead4
commit 33b6fbdee9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1373 additions and 400 deletions

View File

@ -0,0 +1,67 @@
// @flow
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionNew from "scenes/CollectionNew";
import DynamicCollectionIcon from "components/CollectionIcon";
import { createAction } from "actions";
import { CollectionSection } from "actions/sections";
import history from "utils/history";
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
section: CollectionSection,
shortcut: ["o", "c"],
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) => ({
id: collection.id,
name: collection.name,
icon: <DynamicCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.url),
}));
},
});
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
section: CollectionSection,
icon: <PlusIcon />,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create a collection"),
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const editCollection = createAction({
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
stores.dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={activeCollectionId}
/>
),
});
},
});
export const rootCollectionActions = [openCollection, createCollection];

View File

@ -0,0 +1,33 @@
// @flow
import { ToolsIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { DebugSection } from "actions/sections";
import env from "env";
import { deleteAllDatabases } from "utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DebugSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const development = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DebugSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB],
});
export const rootDebugActions = [development];

View File

@ -0,0 +1,88 @@
// @flow
import {
StarredIcon,
DocumentIcon,
NewDocumentIcon,
ImportIcon,
} from "outline-icons";
import * as React from "react";
import { createAction } from "actions";
import { DocumentSection } from "actions/sections";
import getDataTransferFiles from "utils/getDataTransferFiles";
import history from "utils/history";
import { newDocumentPath } from "utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
section: DocumentSection,
shortcut: ["o", "d"],
icon: <DocumentIcon />,
children: ({ stores }) => {
const paths = stores.collections.pathsToDocuments;
return paths
.filter((path) => path.type === "document")
.map((path) => ({
id: path.id,
name: path.title,
icon: () =>
stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : undefined,
section: DocumentSection,
perform: () => history.push(path.url),
}));
},
});
export const createDocument = createAction({
name: ({ t }) => t("New document"),
section: DocumentSection,
icon: <NewDocumentIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
});
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
section: DocumentSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId, stores }) => {
const { documents, toasts } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toasts.showToast(err.message, {
type: "error",
});
throw err;
}
};
input.click();
},
});
export const rootDocumentActions = [openDocument, importDocument];

View File

@ -0,0 +1,159 @@
// @flow
import {
HomeIcon,
SearchIcon,
ArchiveIcon,
TrashIcon,
EditIcon,
OpenIcon,
SettingsIcon,
ShapesIcon,
KeyboardIcon,
EmailIcon,
} from "outline-icons";
import * as React from "react";
import {
developersUrl,
changelogUrl,
mailToUrl,
githubIssuesUrl,
} from "shared/utils/routeHelpers";
import stores from "stores";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { createAction } from "actions";
import { NavigationSection } from "actions/sections";
import history from "utils/history";
import {
settingsPath,
homePath,
searchUrl,
draftsPath,
templatesPath,
archivePath,
trashPath,
} from "utils/routeHelpers";
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
perform: () => history.push(homePath()),
visible: ({ location }) => location.pathname !== homePath(),
});
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"),
section: NavigationSection,
icon: <EditIcon />,
perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToTemplates = createAction({
name: ({ t }) => t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== templatesPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
section: NavigationSection,
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
section: NavigationSection,
icon: <TrashIcon />,
perform: () => history.push(trashPath()),
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(settingsPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(developersUrl()),
});
export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"),
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(mailToUrl()),
});
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
section: NavigationSection,
perform: () => window.open(githubIssuesUrl()),
});
export const openChangelog = createAction({
name: ({ t }) => t("Changelog"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(changelogUrl()),
});
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
section: NavigationSection,
shortcut: ["?"],
iconInContextMenu: false,
icon: <KeyboardIcon />,
perform: ({ t }) => {
stores.dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
});
},
});
export const logout = createAction({
name: ({ t }) => t("Log out"),
section: NavigationSection,
perform: () => stores.auth.logout(),
});
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
openChangelog,
openKeyboardShortcuts,
logout,
];

View File

@ -0,0 +1,48 @@
// @flow
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { SettingsSection } from "actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
section: SettingsSection,
selected: () => stores.ui.theme === "dark",
perform: () => stores.ui.setTheme("dark"),
});
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
section: SettingsSection,
selected: () => stores.ui.theme === "light",
perform: () => stores.ui.setTheme("light"),
});
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
section: SettingsSection,
selected: () => stores.ui.theme === "system",
perform: () => stores.ui.setTheme("system"),
});
export const changeTheme = createAction({
name: ({ t }) => t("Change theme"),
placeholder: ({ t }) => t("Change theme to"),
icon: () =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
});
export const rootSettingsActions = [changeTheme];

View File

@ -0,0 +1,24 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import Invite from "scenes/Invite";
import { createAction } from "actions";
import { UserSection } from "actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite people"),
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const rootUserActions = [inviteUser];

117
app/actions/index.js Normal file
View File

@ -0,0 +1,117 @@
// @flow
import { flattenDeep } from "lodash";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
import type {
Action,
ActionContext,
CommandBarAction,
MenuItemClickable,
MenuItemWithChildren,
} from "types";
export function createAction(
definition: $Diff<Action, { id?: string }>
): Action {
return {
id: uuidv4(),
...definition,
};
}
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemClickable | MenuItemWithChildren {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined;
if (resolvedChildren) {
return {
title,
icon,
items: resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter((a) => !!a),
visible,
};
}
return {
title,
icon,
visible,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
}
export function actionToKBar(
action: Action,
context: ActionContext
): CommandBarAction[] {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedSection = resolve<string>(action.section);
const resolvedName = resolve<string>(action.name);
const resolvedPlaceholder = resolve<string>(action.placeholder);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
return [
{
id: action.id,
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: `${action.keywords || ""} ${children
.filter((c) => !!c.keywords)
.map((c) => c.keywords)
.join(" ")}`,
shortcut: action.shortcut,
icon: resolvedIcon
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
children: children.length ? children.map((a) => a.id) : undefined,
},
].concat(
children.map((child) => ({
...child,
parent: action.id,
}))
);
}

16
app/actions/root.js Normal file
View File

@ -0,0 +1,16 @@
// @flow
import { rootCollectionActions } from "./definitions/collections";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
import { rootUserActions } from "./definitions/users";
export default [
...rootCollectionActions,
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDebugActions,
];

14
app/actions/sections.js Normal file
View File

@ -0,0 +1,14 @@
// @flow
import { type ActionContext } from "types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");

View File

@ -0,0 +1,87 @@
// @flow
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import CommandBarResults from "components/CommandBarResults";
import rootActions from "actions/root";
import useCommandBarActions from "hooks/useCommandBarActions";
export const CommandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
};
function CommandBar() {
const { t } = useTranslation();
useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.actions[state.currentRootActionId],
}));
return (
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
);
}
function KBarPortal({ children }: { children: React.Node }) {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
}
const Positioner = styled(KBarPositioner)`
z-index: ${(props) => props.theme.depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
padding: 16px 20px;
width: 100%;
outline: none;
border: none;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const Animator = styled(KBarAnimator)`
max-width: 540px;
max-height: 75vh;
width: 90vw;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
`;
export default observer(CommandBar);

View File

@ -0,0 +1,60 @@
// @flow
import { BackIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
import Key from "components/Key";
import type { CommandBarAction } from "types";
type Props = {|
action: CommandBarAction,
active: Boolean,
|};
function CommandBarItem({ action, active }: Props, ref) {
return (
<Item active={active} ref={ref}>
<Flex align="center" gap={8}>
<Icon>
{action.icon ? (
React.cloneElement(action.icon, { size: 22 })
) : (
<ForwardIcon color="currentColor" size={22} />
)}
</Icon>
{action.name}
{action.children?.length ? "…" : ""}
</Flex>
{action.shortcut?.length ? (
<div style={{ display: "grid", gridAutoFlow: "column", gap: "4px" }}>
{action.shortcut.map((sc) => (
<Key key={sc}>{sc}</Key>
))}
</div>
) : null}
</Item>
);
}
const Icon = styled.div`
width: 22px;
height: 22px;
color: ${(props) => props.theme.textSecondary};
`;
const Item = styled.div`
font-size: 15px;
padding: 12px 16px;
background: ${(props) =>
props.active ? props.theme.menuItemSelected : "none"};
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
`;
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
`;
export default React.forwardRef<Props, HTMLDivElement>(CommandBarItem);

View File

@ -0,0 +1,44 @@
// @flow
import { useMatches, KBarResults, NO_GROUP } from "kbar";
import * as React from "react";
import styled from "styled-components";
import CommandBarItem from "components/CommandBarItem";
export default function CommandBarResults() {
const matches = useMatches();
const items = React.useMemo(
() =>
matches
.reduce((acc, curr) => {
const { actions, name } = curr;
acc.push(name);
acc.push(...actions);
return acc;
}, [])
.filter((i) => i !== NO_GROUP),
[matches]
);
return (
<KBarResults
items={items}
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header>{item}</Header>
) : (
<CommandBarItem action={item} active={active} />
)
}
/>
);
}
const Header = styled.h3`
font-size: 13px;
letter-spacing: 0.04em;
margin: 0;
padding: 16px 0 4px 20px;
color: ${(props) => props.theme.textTertiary};
height: 36px;
`;

View File

@ -2,7 +2,7 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import {
useMenuState,
MenuButton,
@ -15,10 +15,20 @@ import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { type MenuItem as TMenuItem } from "types";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type {
MenuItem as TMenuItem,
Action,
ActionContext,
MenuSeparator,
MenuHeading,
} from "types";
type Props = {|
items: TMenuItem[],
actions: (Action | MenuSeparator | MenuHeading)[],
context?: $Shape<ActionContext>,
|};
const Disclosure = styled(ExpandedIcon)`
@ -68,8 +78,30 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
const filteredTemplates = filterTemplateItems(items);
function Template({ items, actions, context, ...menu }: Props): React.Node {
const { t } = useTranslation();
const location = useLocation();
const stores = useStores();
const { ui } = stores;
const ctx = {
t,
isCommandBar: false,
isContextMenu: true,
activeCollectionId: ui.activeCollectionId,
activeDocumentId: ui.activeDocumentId,
location,
stores,
...context,
};
const filteredTemplates = filterTemplateItems(
actions
? actions.map((action) =>
action.type ? action : actionToMenuItem(action, ctx)
)
: items
);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !item.type && !!item.icon
);

View File

@ -123,6 +123,7 @@ export const Background = styled.div`
border-radius: 6px;
padding: 6px 0;
min-width: 180px;
min-height: 44px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;

37
app/components/Dialogs.js Normal file
View File

@ -0,0 +1,37 @@
// @flow
import { observer } from "mobx-react-lite";
import * as React from "react";
import Guide from "components/Guide";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
function Dialogs() {
const { dialogs } = useStores();
const { guide, modalStack } = dialogs;
return (
<>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{[...modalStack].map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
{modal.content}
</Modal>
))}
</>
);
}
export default observer(Dialogs);

View File

@ -20,7 +20,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {|
document: Document,
@ -132,7 +132,7 @@ function DocumentListItem(props: Props, ref) {
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}

View File

@ -162,8 +162,6 @@ const CardContent = styled.div`
const Card = styled.div`
backdrop-filter: blur(10px);
background: ${(props) => props.theme.background};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);

View File

@ -4,12 +4,11 @@ import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import { withTranslation } from "react-i18next";
import keydown from "react-keydown";
import {
Switch,
Route,
Redirect,
withRouter,
type RouterHistory,
} from "react-router-dom";
@ -20,21 +19,20 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Button from "components/Button";
import Flex from "components/Flex";
import Guide from "components/Guide";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import env from "env";
import { meta } from "utils/keyboard";
import {
homeUrl,
searchUrl,
matchDocumentSlug as slug,
newDocumentUrl,
newDocumentPath,
settingsPath,
} from "utils/routeHelpers";
const DocumentHistory = React.lazy(() =>
@ -43,6 +41,13 @@ const DocumentHistory = React.lazy(() =>
)
);
const CommandBar = React.lazy(() =>
import(
/* webpackChunkName: "command-bar" */
"components/CommandBar"
)
);
type Props = {
documents: DocumentsStore,
children?: ?React.Node,
@ -53,46 +58,27 @@ type Props = {
history: RouterHistory,
policies: PoliciesStore,
notifications?: React.Node,
i18n: Object,
t: TFunction,
};
@observer
class Layout extends React.Component<Props> {
scrollable: ?HTMLDivElement;
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
componentDidUpdate() {
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
this.keyboardShortcutsOpen = true;
}
handleCloseKeyboardShortcuts = () => {
this.keyboardShortcutsOpen = false;
};
@keydown(["t", "/", `${meta}+k`])
@keydown([
"t",
"/",
env.ENVIRONMENT === "development" ? undefined : `${meta}+k`,
])
goToSearch(ev: SyntheticEvent<>) {
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
}
@keydown("d")
goToDashboard() {
this.redirectTo = homeUrl();
this.props.history.push(searchUrl());
}
@keydown("n")
@ -103,17 +89,16 @@ class Layout extends React.Component<Props> {
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) return;
this.props.history.push(newDocumentUrl(activeCollectionId));
this.props.history.push(newDocumentPath(activeCollectionId));
}
render() {
const { auth, t, ui } = this.props;
const { auth, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Container column auto>
@ -139,7 +124,7 @@ class Layout extends React.Component<Props> {
<Container auto>
{showSidebar && (
<Switch>
<Route path="/settings" component={SettingsSidebar} />
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
)}
@ -168,13 +153,7 @@ class Layout extends React.Component<Props> {
</Switch>
</React.Suspense>
</Container>
<Guide
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
<CommandBar />
</Container>
);
}

View File

@ -52,7 +52,9 @@ const Modal = ({
}
});
if (!isOpen) return null;
if (!isOpen && !wasOpen) {
return null;
}
return (
<DialogBackdrop {...dialog}>

View File

@ -27,8 +27,6 @@ const Contents = styled.div`
overflow-y: scroll;
width: ${(props) => props.width}px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default Popover;

View File

@ -11,14 +11,17 @@ type Props = {|
flex?: boolean,
|};
function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
const ref = React.useRef<?HTMLDivElement>();
function Scrollable(
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
ref: any
) {
const fallbackRef = React.useRef<?HTMLDivElement>();
const [topShadowVisible, setTopShadow] = React.useState(false);
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
const { height } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = ref.current;
const c = (ref || fallbackRef).current;
if (!c) return;
const scrollTop = c.scrollTop;
@ -33,7 +36,14 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
}
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
}, [
shadow,
topShadow,
bottomShadow,
ref,
topShadowVisible,
bottomShadowVisible,
]);
React.useEffect(() => {
updateShadows();
@ -41,7 +51,7 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
return (
<Wrapper
ref={ref}
ref={ref || fallbackRef}
onScroll={updateShadows}
$flex={flex}
$topShadowVisible={topShadowVisible}
@ -75,4 +85,4 @@ const Wrapper = styled.div`
transition: all 100ms ease-in-out;
`;
export default observer(Scrollable);
export default observer(React.forwardRef(Scrollable));

View File

@ -5,7 +5,6 @@ import {
SearchIcon,
ShapesIcon,
HomeIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
@ -13,62 +12,42 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
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 SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import { inviteUser } from "actions/definitions/users";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
import {
homePath,
searchUrl,
draftsPath,
templatesPath,
settingsPath,
} from "utils/routeHelpers";
function MainSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
setCreateCollectionModalOpen,
] = React.useState(false);
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
const handleCreateCollectionModalOpen = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
setCreateCollectionModalOpen(true);
},
[]
);
const handleCreateCollectionModalClose = React.useCallback(() => {
setCreateCollectionModalOpen(false);
}, []);
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
setInviteModalOpen(true);
}, []);
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
const html5Options = React.useMemo(() => ({ rootElement: dndArea }), [
@ -95,14 +74,14 @@ function MainSidebar() {
<Scrollable flex topShadow>
<Section>
<SidebarLink
to="/home"
to={homePath()}
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={{
pathname: "/search",
pathname: searchUrl(),
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
@ -111,7 +90,7 @@ function MainSidebar() {
/>
{can.createDocument && (
<SidebarLink
to="/drafts"
to={draftsPath()}
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
@ -131,15 +110,13 @@ function MainSidebar() {
</Section>
<Starred />
<Section auto>
<Collections
onCreateCollection={handleCreateCollectionModalOpen}
/>
<Collections />
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
to={templatesPath()}
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
@ -156,37 +133,14 @@ function MainSidebar() {
</>
)}
<SidebarLink
to="/settings"
to={settingsPath()}
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
{can.inviteUser && (
<SidebarLink
to="/settings/members"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
)}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</DndProvider>
)}
</Sidebar>

View File

@ -4,9 +4,10 @@ import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { archivePath } from "utils/routeHelpers";
function ArchiveLink({ documents }) {
const { policies } = useStores();
@ -29,7 +30,7 @@ function ArchiveLink({ documents }) {
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to="/archive"
to={archivePath()}
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}

View File

@ -1,7 +1,7 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { PlusIcon, CollapsedIcon } from "outline-icons";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
@ -12,15 +12,12 @@ import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarAction from "./SidebarAction";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import { createCollection } from "actions/definitions/collections";
import useToasts from "hooks/useToasts";
type Props = {
onCreateCollection: () => void,
};
function Collections({ onCreateCollection }: Props) {
function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
@ -28,9 +25,7 @@ function Collections({ onCreateCollection }: Props) {
const [expanded, setExpanded] = React.useState(true);
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
const orderedCollections = collections.orderedData;
const can = policies.abilities(team.id);
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
false
);
@ -93,16 +88,7 @@ function Collections({ onCreateCollection }: Props) {
belowCollection={orderedCollections[index + 1]}
/>
))}
{can.createCollection && (
<SidebarLink
to="/collections"
onClick={onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
depth={0.5}
/>
)}
<SidebarAction action={createCollection} depth={0.5} />
</>
);

View File

@ -0,0 +1,44 @@
// @flow
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import SidebarLink from "./SidebarLink";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type { Action } from "types";
type Props = {|
action: Action,
|};
function SidebarAction({ action, ...rest }: Props) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
isContextMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
location,
stores,
t,
};
const menuItem = actionToMenuItem(action, context);
invariant(menuItem.onClick, "passed action must have perform");
return (
<SidebarLink
onClick={menuItem.onClick}
icon={menuItem.icon}
label={menuItem.title}
{...rest}
/>
);
}
export default observer(SidebarAction);

View File

@ -7,8 +7,9 @@ import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import { trashPath } from "utils/routeHelpers";
function TrashLink({ documents }) {
const { policies } = useStores();
@ -33,7 +34,7 @@ function TrashLink({ documents }) {
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to="/trash"
to={trashPath()}
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}

View File

@ -0,0 +1,30 @@
// @flow
import { useRegisterActions } from "kbar";
import { flattenDeep } from "lodash";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { actionToKBar } from "actions";
import useStores from "hooks/useStores";
import type { Action } from "types";
export default function useCommandBarActions(actions: Action[]) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
t,
isCommandBar: true,
isContextMenu: false,
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
location,
stores,
};
const registerable = flattenDeep(
actions.map((action) => actionToKBar(action, context))
);
useRegisterActions(registerable, [registerable.length, location.pathname]);
}

View File

@ -1,7 +1,7 @@
// @flow
import "focus-visible";
import { LazyMotion } from "framer-motion";
import { createBrowserHistory } from "history";
import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
@ -9,19 +9,21 @@ 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";
import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme";
import Toasts from "components/Toasts";
import Routes from "./routes";
import history from "./utils/history";
import { initSentry } from "./utils/sentry";
import env from "env";
initI18n();
const element = window.document.getElementById("root");
const history = createBrowserHistory();
if (env.SENTRY_DSN) {
initSentry(history);
@ -61,17 +63,20 @@ if (element) {
<Analytics>
<Theme>
<ErrorBoundary>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</LazyMotion>
<KBarProvider actions={[]} options={CommandBarOptions}>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
</>
</Router>
</LazyMotion>
</KBarProvider>
</ErrorBoundary>
</Theme>
</Analytics>

View File

@ -1,29 +1,27 @@
// @flow
import { observer } from "mobx-react";
import { MoonIcon, SunIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import {
changelog,
developers,
githubIssuesUrl,
mailToUrl,
settings,
} from "shared/utils/routeHelpers";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import Guide from "components/Guide";
import env from "env";
import useBoolean from "hooks/useBoolean";
import { development } from "actions/definitions/debug";
import {
navigateToSettings,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
openBugReportUrl,
openFeedbackUrl,
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 useToasts from "hooks/useToasts";
import { deleteAllDatabases } from "utils/developer";
import separator from "menus/separator";
type Props = {|
children: (props: any) => React.Node,
@ -36,18 +34,11 @@ function AccountMenu(props: Props) {
placement: "bottom-start",
modal: true,
});
const { showToast } = useToasts();
const { auth, ui } = useStores();
const { theme, resolvedTheme } = ui;
const { ui } = useStores();
const { theme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
const [includeAlt, setIncludeAlt] = React.useState(false);
const [
keyboardShortcutsOpen,
handleKeyboardShortcutsOpen,
handleKeyboardShortcutsClose,
] = useBoolean();
React.useEffect(() => {
if (theme !== previousTheme) {
@ -55,132 +46,43 @@ function AccountMenu(props: Props) {
}
}, [menu, theme, previousTheme]);
const handleDeleteAllDatabases = React.useCallback(async () => {
await deleteAllDatabases();
showToast("IndexedDB cache deleted");
menu.hide();
}, [showToast, menu]);
const handleOpenMenu = React.useCallback((event) => {
setIncludeAlt(event.altKey);
}, []);
const items = React.useMemo(() => {
const actions = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
{
title: t("Settings"),
to: settings(),
},
{
title: t("Keyboard shortcuts"),
onClick: handleKeyboardShortcutsOpen,
},
{
title: t("API documentation"),
href: developers(),
},
{
type: "separator",
},
{
title: t("Changelog"),
href: changelog(),
},
{
title: t("Send us feedback"),
href: mailToUrl(),
},
{
title: t("Report a bug"),
href: githubIssuesUrl(),
},
...(includeAlt || env.ENVIRONMENT === "development"
? [
{
title: t("Development"),
items: [
{
title: "Delete IndexedDB cache",
icon: <TrashIcon />,
onClick: handleDeleteAllDatabases,
},
],
},
]
: []),
{
title: t("Appearance"),
icon: resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
items: [
{
title: t("System"),
onClick: () => ui.setTheme("system"),
selected: theme === "system",
},
{
title: t("Light"),
onClick: () => ui.setTheme("light"),
selected: theme === "light",
},
{
title: t("Dark"),
onClick: () => ui.setTheme("dark"),
selected: theme === "dark",
},
],
},
{
type: "separator",
},
navigateToSettings,
openKeyboardShortcuts,
openAPIDocumentation,
separator(),
openChangelog,
openFeedbackUrl,
openBugReportUrl,
development,
changeTheme,
separator(),
...(otherSessions.length
? [
{
title: t("Switch team"),
items: otherSessions.map((session) => ({
title: session.name,
name: t("Switch team"),
children: otherSessions.map((session) => ({
name: session.name,
icon: <Logo alt={session.name} src={session.logoUrl} />,
href: session.url,
perform: () => (window.location.href = session.url),
})),
},
]
: []),
{
title: t("Log out"),
onClick: auth.logout,
},
logout,
];
}, [
auth.logout,
team.id,
team.url,
sessions,
handleKeyboardShortcutsOpen,
handleDeleteAllDatabases,
resolvedTheme,
includeAlt,
theme,
t,
ui,
]);
}, [team.id, team.url, sessions, t]);
return (
<>
<Guide
isOpen={keyboardShortcutsOpen}
onRequestClose={handleKeyboardShortcutsClose}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
<MenuButton {...menu} onClick={handleOpenMenu}>
{props.children}
</MenuButton>
<MenuButton {...menu}>{props.children}</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<Template {...menu} items={items} />
<Template {...menu} actions={actions} />
</ContextMenu>
</>
);

View File

@ -26,7 +26,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {|
collection: Collection,
@ -72,7 +72,7 @@ function CollectionMenu({
const handleNewDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
history.push(newDocumentUrl(collection.id));
history.push(newDocumentPath(collection.id));
},
[history, collection.id]
);
@ -225,7 +225,7 @@ function CollectionMenu({
>
<CollectionEdit
onSubmit={() => setShowCollectionEdit(false)}
collection={collection}
collectionId={collection.id}
/>
</Modal>
<Modal

View File

@ -45,7 +45,7 @@ import {
documentHistoryUrl,
documentUrl,
editDocumentUrl,
newDocumentUrl,
newDocumentPath,
} from "utils/routeHelpers";
type Props = {|
@ -330,7 +330,7 @@ function DocumentMenu({
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
visible: !!can.createChildDocument,

View File

@ -7,7 +7,7 @@ import Document from "models/Document";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {
label?: (any) => React.Node,
@ -38,11 +38,11 @@ function NewChildDocumentMenu({ document, label }: Props) {
/>
</span>
),
to: newDocumentUrl(document.collectionId),
to: newDocumentPath(document.collectionId),
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
},

View File

@ -13,7 +13,7 @@ import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
function NewDocumentMenu() {
const menu = useMenuState({ modal: true });
@ -29,7 +29,7 @@ function NewDocumentMenu() {
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id),
to: newDocumentPath(collection.id),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});

View File

@ -12,7 +12,7 @@ import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
function NewTemplateMenu() {
const menu = useMenuState({ modal: true });
@ -27,7 +27,7 @@ function NewTemplateMenu() {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id, { template: true }),
to: newDocumentPath(collection.id, { template: true }),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});

7
app/menus/separator.js Normal file
View File

@ -0,0 +1,7 @@
// @flow
export default function separator() {
return {
type: "separator",
};
}

View File

@ -39,13 +39,15 @@ import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import { editCollection } from "actions/definitions/collections";
import useBoolean from "hooks/useBoolean";
import useCommandBarActions from "hooks/useCommandBarActions";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import CollectionMenu from "menus/CollectionMenu";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
import { newDocumentPath, collectionUrl } from "utils/routeHelpers";
function CollectionScene() {
const params = useParams();
@ -109,6 +111,8 @@ function CollectionScene() {
load();
}, [collections, isFetching, collection, error, id, can]);
useCommandBarActions([editCollection]);
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
@ -158,7 +162,7 @@ function CollectionScene() {
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
@ -227,7 +231,7 @@ function CollectionScene() {
</HelpText>
<Empty>
{canUser.createDocument && (
<Link to={newDocumentUrl(collection.id)}>
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
@ -388,6 +392,7 @@ const DropMessage = styled(HelpText)`
`;
const DropzoneContainer = styled.div`
outline-color: transparent !important;
min-height: calc(100% - 56px);
position: relative;

View File

@ -8,7 +8,7 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { homeUrl } from "utils/routeHelpers";
import { homePath } from "utils/routeHelpers";
type Props = {
collection: Collection,
@ -28,7 +28,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
try {
await collection.delete();
history.push(homeUrl());
history.push(homePath());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });

View File

@ -1,23 +1,28 @@
// @flow
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputSelect from "components/InputSelect";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
collection: Collection,
collectionId: string,
onSubmit: () => void,
};
const CollectionEdit = ({ collection, onSubmit }: Props) => {
const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
const { collections } = useStores();
const collection = collections.get(collectionId);
invariant(collection, "Collection not found");
const [name, setName] = useState(collection.name);
const [icon, setIcon] = useState(collection.icon);
const [color, setColor] = useState(collection.color || "#4E5C6E");

View File

@ -31,7 +31,7 @@ import TableOfContentsMenu from "menus/TableOfContentsMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath, editDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
@ -255,7 +255,7 @@ function DocumentHeader({
<Button
icon={<PlusIcon />}
as={Link}
to={newDocumentUrl(document.collectionId, {
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
primary

View File

@ -14,7 +14,7 @@ import usePageVisibility from "hooks/usePageVisibility";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { homeUrl } from "utils/routeHelpers";
import { homePath } from "utils/routeHelpers";
type Props = {|
...EditorProps,
@ -61,7 +61,7 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
)
);
history.replace(homeUrl());
history.replace(homePath());
});
provider.on("awarenessChange", ({ states }) => {

View File

@ -3,12 +3,12 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { groupSettings } from "shared/utils/routeHelpers";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { groupSettingsPath } from "utils/routeHelpers";
type Props = {|
group: Group,
@ -27,7 +27,7 @@ function GroupDelete({ group, onSubmit }: Props) {
try {
await group.delete();
history.push(groupSettings());
history.push(groupSettingsPath());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });

View File

@ -36,8 +36,7 @@ import StatusFilter from "./components/StatusFilter";
import UserFilter from "./components/UserFilter";
import NewDocumentMenu from "menus/NewDocumentMenu";
import { type LocationWithState } from "types";
import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, searchUrl } from "utils/routeHelpers";
import { newDocumentPath, searchUrl } from "utils/routeHelpers";
import { decodeURIComponentSafe } from "utils/urls";
type Props = {
@ -153,7 +152,7 @@ class Search extends React.Component<Props> {
handleNewDoc = () => {
if (this.collectionId) {
this.props.history.push(newDocumentUrl(this.collectionId));
this.props.history.push(newDocumentPath(this.collectionId));
}
};
@ -289,8 +288,8 @@ class Search extends React.Component<Props> {
<Fade>
<HelpText small>
<Trans
defaults="Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base"
values={{ meta: metaDisplay }}
defaults="Use the <em>{{ shortcut }}</em> shortcut to search from anywhere in your knowledge base"
values={{ shortcut: "/" }}
components={{ em: <strong /> }}
/>
</HelpText>

View File

@ -49,7 +49,7 @@ function Features() {
<HelpText>
<Trans>
Manage optional and beta features. Changing these settings will affect
all team members.
the experience for all team members.
</Trans>
</HelpText>
<Checkbox
@ -57,7 +57,13 @@ function Features() {
name="collaborativeEditing"
checked={data.collaborativeEditing}
onChange={handleChange}
note="When enabled multiple people can edit documents at the same time (Beta)"
note={
<Trans>
When enabled multiple people can edit documents at the same time.
Please note that this feature is in beta and currently disables
updating the document via the API.
</Trans>
}
/>
</Scene>
);

View File

@ -6,7 +6,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import { settings } from "shared/utils/routeHelpers";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
@ -18,6 +17,7 @@ import PaginatedDocumentList from "components/PaginatedDocumentList";
import Subheading from "components/Subheading";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { settingsPath } from "utils/routeHelpers";
type Props = {|
user: User,
@ -61,7 +61,7 @@ function UserProfile(props: Props) {
{isCurrentUser && (
<Edit>
<Button
onClick={() => history.push(settings())}
onClick={() => history.push(settingsPath())}
icon={<EditIcon />}
neutral
>

View File

@ -0,0 +1,73 @@
// @flow
import { observable, action } from "mobx";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
export default class DialogsStore {
@observable guide: {
title: string,
content: React.Node,
isOpen: boolean,
};
@observable modalStack = new Map<
string,
{
title: string,
content: React.Node,
isOpen: boolean,
}
>();
openGuide = ({ title, content }: { title: string, content: React.Node }) => {
setTimeout(
action(() => {
this.guide = { title, content, isOpen: true };
}),
0
);
};
@action
closeGuide = () => {
if (this.guide) {
this.guide.isOpen = false;
}
};
openModal = ({
title,
content,
replace,
}: {
title: string,
content: React.Node,
replace?: boolean,
}) => {
setTimeout(
action(() => {
const id = uuidv4();
if (replace) {
this.modalStack.clear();
}
this.modalStack.set(id, {
title,
content,
isOpen: true,
});
}),
0
);
};
@action
closeModal = (id: string) => {
this.modalStack.delete(id);
};
@action
closeAllModals = () => {
this.modalStack.clear();
};
}

View File

@ -3,6 +3,7 @@ import ApiKeysStore from "./ApiKeysStore";
import AuthStore from "./AuthStore";
import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
import CollectionsStore from "./CollectionsStore";
import DialogsStore from "./DialogsStore";
import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore";
import EventsStore from "./EventsStore";
@ -25,6 +26,7 @@ export default class RootStore {
auth: AuthStore;
collections: CollectionsStore;
collectionGroupMemberships: CollectionGroupMembershipsStore;
dialogs: DialogsStore;
documents: DocumentsStore;
events: EventsStore;
groups: GroupsStore;
@ -49,6 +51,7 @@ export default class RootStore {
this.auth = new AuthStore(this);
this.collections = new CollectionsStore(this);
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
this.dialogs = new DialogsStore();
this.documents = new DocumentsStore(this);
this.events = new EventsStore(this);
this.groups = new GroupsStore(this);

View File

@ -1,10 +1,81 @@
// @flow
import { type TFunction } from "react-i18next";
import { type Location } from "react-router-dom";
import theme from "shared/theme";
import RootStore from "stores/RootStore";
import Document from "models/Document";
export type Theme = typeof theme;
export type MenuItemClickable = {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
icon?: React.Node,
|};
export type MenuItemWithChildren = {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: MenuItem[],
icon?: React.Node,
|};
export type MenuSeparator = {|
type: "separator",
visible?: boolean,
|};
export type MenuHeading = {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
export type ActionContext = {
isContextMenu: boolean,
isCommandBar: boolean,
activeCollectionId: ?string,
activeDocumentId: ?string,
location: Location,
stores: RootStore,
event?: Event,
t: TFunction,
};
export type Action = {|
id: string,
name: ((ActionContext) => string) | string,
section: ((ActionContext) => string) | string,
shortcut?: string[],
keywords?: string,
iconInContextMenu?: boolean,
icon?: React.Element,
placeholder?: ((ActionContext) => string) | string,
selected?: (ActionContext) => boolean,
visible?: (ActionContext) => boolean,
perform?: (ActionContext) => any,
children?: ((ActionContext) => Action[]) | Action[],
|};
export type CommandBarAction = {|
id: string,
name: string,
section: string,
shortcut?: string[],
keywords?: string,
placeholder?: string,
icon?: React.Element,
perform?: () => any,
children?: string[],
parent?: string,
|};
export type LocationWithState = Location & {
state: {
[key: string]: string,
@ -68,14 +139,7 @@ export type MenuItem =
disabled?: boolean,
icon?: React.Node,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
icon?: React.Node,
|}
| MenuItemClickable
| {|
title: React.Node,
href: string,
@ -85,24 +149,9 @@ export type MenuItem =
level?: number,
icon?: React.Node,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: MenuItem[],
icon?: React.Node,
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
| MenuItemWithChildren
| MenuSeparator
| MenuHeading;
export type ToastOptions = {|
type: "warning" | "error" | "info" | "success",

6
app/utils/history.js Normal file
View File

@ -0,0 +1,6 @@
// @flow
import { createBrowserHistory } from "history";
const history = createBrowserHistory();
export default history;

View File

@ -3,12 +3,32 @@ import queryString from "query-string";
import Collection from "models/Collection";
import Document from "models/Document";
export function homeUrl(): string {
export function homePath(): string {
return "/home";
}
export function newCollectionUrl(): string {
return "/collection/new";
export function draftsPath(): string {
return "/drafts";
}
export function templatesPath(): string {
return "/templates";
}
export function settingsPath(): string {
return "/settings";
}
export function archivePath(): string {
return "/archive";
}
export function trashPath(): string {
return "/trash";
}
export function groupSettingsPath(): string {
return "/settings/groups";
}
export function collectionUrl(url: string, section: ?string): string {
@ -54,7 +74,7 @@ export function updateDocumentUrl(oldUrl: string, document: Document): string {
return oldUrl.replace(new RegExp("/doc/[0-9a-zA-Z-_~]*"), document.url);
}
export function newDocumentUrl(
export function newDocumentPath(
collectionId: string,
params?: {
parentDocumentId?: string,

View File

@ -13,6 +13,7 @@ app
├── components - React components reusable across scenes
├── embeds - Embed definitions that represent rich interactive embeds in the editor
├── hooks - Reusable React hooks
├── actions - Reusable actions such as navigating, opening, creating entities
├── menus - Context menus, often appear in multiple places in the UI
├── models - State models using MobX observables
├── routes - Route definitions, note that chunks are async loaded with suspense

View File

@ -97,6 +97,7 @@
"json-loader": "0.5.4",
"jsonwebtoken": "^8.5.0",
"jszip": "^3.5.0",
"kbar": "^0.1.0-beta.15",
"koa": "^2.10.0",
"koa-body": "^4.2.0",
"koa-compress": "2.0.0",
@ -144,6 +145,7 @@
"react-portal": "^4.2.0",
"react-router-dom": "^5.2.0",
"react-table": "^7.7.0",
"react-virtual": "^2.8.2",
"react-virtualized-auto-sizer": "^1.0.5",
"react-waypoint": "^10.1.0",
"react-window": "^1.8.6",
@ -225,4 +227,4 @@
"js-yaml": "^3.13.1"
},
"version": "0.59.0"
}
}

View File

@ -1,4 +1,38 @@
{
"Open collection": "Open collection",
"New collection": "New collection",
"Create a collection": "Create a collection",
"Edit collection": "Edit collection",
"Delete IndexedDB cache": "Delete IndexedDB cache",
"IndexedDB cache deleted": "IndexedDB cache deleted",
"Development": "Development",
"Open document": "Open document",
"New document": "New document",
"Import document": "Import document",
"Home": "Home",
"Search": "Search",
"Drafts": "Drafts",
"Templates": "Templates",
"Archive": "Archive",
"Trash": "Trash",
"Settings": "Settings",
"API documentation": "API documentation",
"Send us feedback": "Send us feedback",
"Report a bug": "Report a bug",
"Changelog": "Changelog",
"Keyboard shortcuts": "Keyboard shortcuts",
"Log out": "Log out",
"Dark": "Dark",
"Light": "Light",
"System": "System",
"Change theme": "Change theme",
"Change theme to": "Change theme to",
"Invite people": "Invite people",
"Collection": "Collection",
"Debug": "Debug",
"Document": "Document",
"Navigation": "Navigation",
"People": "People",
"currently editing": "currently editing",
"currently viewing": "currently viewing",
"previously edited": "previously edited",
@ -8,13 +42,10 @@
"Add a description": "Add a description",
"Collapse": "Collapse",
"Expand": "Expand",
"Type a command or search": "Type a command or search",
"Server connection lost": "Server connection lost",
"Edits you make will sync once youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu",
"Trash": "Trash",
"Archive": "Archive",
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"History": "History",
"Oh weird, there's nothing here": "Oh weird, there's nothing here",
@ -127,7 +158,6 @@
"Choose icon": "Choose icon",
"Loading": "Loading",
"Loading editor": "Loading editor",
"Search": "Search",
"Default access": "Default access",
"View and edit": "View and edit",
"View only": "View only",
@ -139,12 +169,10 @@
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?",
"Change Language": "Change Language",
"Dismiss": "Dismiss",
"Keyboard shortcuts": "Keyboard shortcuts",
"Back": "Back",
"Document archived": "Document archived",
"Move document": "Move document",
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
"New collection": "New collection",
"Collections": "Collections",
"Untitled": "Untitled",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word",
@ -154,10 +182,6 @@
"Show less": "Show less",
"Toggle sidebar": "Toggle sidebar",
"Delete {{ documentName }}": "Delete {{ documentName }}",
"Home": "Home",
"Settings": "Settings",
"Invite people": "Invite people",
"Create a collection": "Create a collection",
"Return to App": "Back to App",
"Account": "Account",
"Profile": "Profile",
@ -179,29 +203,15 @@
"Previous page": "Previous page",
"Next page": "Next page",
"Could not import file": "Could not import file",
"API documentation": "API documentation",
"Changelog": "Changelog",
"Send us feedback": "Send us feedback",
"Report a bug": "Report a bug",
"Development": "Development",
"Appearance": "Appearance",
"System": "System",
"Light": "Light",
"Dark": "Dark",
"Switch team": "Switch team",
"Log out": "Log out",
"Show path to document": "Show path to document",
"Path to document": "Path to document",
"Group member options": "Group member options",
"Remove": "Remove",
"New document": "New document",
"Import document": "Import document",
"Edit": "Edit",
"Permissions": "Permissions",
"Delete": "Delete",
"Collection": "Collection",
"Collection permissions": "Collection permissions",
"Edit collection": "Edit collection",
"Delete collection": "Delete collection",
"Export collection": "Export collection",
"Show sort menu": "Show sort menu",
@ -433,7 +443,6 @@
"Add another": "Add another",
"Inviting": "Inviting",
"Send Invites": "Send Invites",
"Navigation": "Navigation",
"Edit current document": "Edit current document",
"Move current document": "Move current document",
"Jump to search": "Jump to search",
@ -489,7 +498,7 @@
"Author": "Author",
"Not Found": "Not Found",
"We were unable to find the page youre looking for.": "We were unable to find the page youre looking for.",
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
"Use the <em>{{ shortcut }}</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ shortcut }}</em> shortcut to search from anywhere in your knowledge base",
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
"Create a new document?": "Create a new document?",
"Clear filters": "Clear filters",
@ -515,8 +524,9 @@
"Upload": "Upload",
"Subdomain": "Subdomain",
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
"Manage optional and beta features. Changing these settings will affect all team members.": "Manage optional and beta features. Changing these settings will affect all team members.",
"Manage optional and beta features. Changing these settings will affect the experience for all team members.": "Manage optional and beta features. Changing these settings will affect the experience for all team members.",
"Collaborative editing": "Collaborative editing",
"When enabled multiple people can edit documents at the same time. Please note that this feature is in beta and currently disables updating the document via the API.": "When enabled multiple people can edit documents at the same time. Please note that this feature is in beta and currently disables updating the document via the API.",
"New group": "New group",
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
"All groups": "All groups",

View File

@ -126,6 +126,7 @@ export const base = {
popover: 9000,
titleBarDivider: 10000,
loadingIndicatorBar: 20000,
commandBar: 30000,
},
};
@ -147,9 +148,10 @@ export const light = {
backdrop: "rgba(0, 0, 0, 0.2)",
shadow: "rgba(0, 0, 0, 0.2)",
menuItemSelected: colors.warmGrey,
menuBackground: colors.white,
menuShadow:
"0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 8px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.08), 0 30px 40px rgb(0 0 0 / 8%)",
"0 0 0 1px rgb(0 0 0 / 2%), 0 4px 8px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 0%), 0 30px 40px rgb(0 0 0 / 8%)",
divider: colors.slateLight,
titleBarDivider: colors.slateLight,
inputBorder: colors.slateLight,
@ -206,10 +208,10 @@ export const dark = {
backdrop: "rgba(255, 255, 255, 0.3)",
shadow: "rgba(0, 0, 0, 0.6)",
menuBorder: lighten(0.1, colors.almostBlack),
menuBackground: lighten(0.015, colors.almostBlack),
menuItemSelected: lighten(0.1, "#1f2128"),
menuBackground: "#1f2128",
menuShadow:
"0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08), inset 0 0 1px rgba(255,255,255,.4)",
"0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08)",
divider: lighten(0.1, colors.almostBlack),
titleBarDivider: darken(0.4, colors.slate),
inputBorder: colors.slateDark,

View File

@ -42,11 +42,11 @@ export function mailToUrl(): string {
return "mailto:hello@getoutline.com";
}
export function developers(): string {
export function developersUrl(): string {
return `https://www.getoutline.com/developers`;
}
export function changelog(): string {
export function changelogUrl(): string {
return `https://www.getoutline.com/changelog`;
}
@ -54,12 +54,4 @@ export function signin(service: string = "slack"): string {
return `${process.env.URL}/auth/${service}`;
}
export function settings(): string {
return `/settings`;
}
export function groupSettings(): string {
return `/settings/groups`;
}
export const SLUG_URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;

View File

@ -2257,6 +2257,27 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
"@reach/portal@^0.16.0":
version "0.16.0"
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.16.0.tgz#1544531d978b770770b718b2872b35652a11e7e3"
integrity sha512-vXJ0O9T+72HiSEWHPs2cx7YbSO7pQsTMhgqPc5aaddIYpo2clJx1PnYuS0lSNlVaDO0IxQhwYq43evXaXnmviw==
dependencies:
"@reach/utils" "0.16.0"
tslib "^2.3.0"
"@reach/utils@0.16.0":
version "0.16.0"
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.16.0.tgz#5b0777cf16a7cab1ddd4728d5d02762df0ba84ce"
integrity sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q==
dependencies:
tiny-warning "^1.0.3"
tslib "^2.3.0"
"@react-aria/i18n@^3.3.2":
version "3.3.2"
resolved "https://registry.yarnpkg.com/@react-aria/i18n/-/i18n-3.3.2.tgz#891902938333c6ab5491b7acb7581f8567045dbc"
@ -6599,6 +6620,11 @@ fast-diff@^1.1.2:
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-equals@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.3.tgz#7039b0a039909f345a2ce53f6202a14e5f392efc"
integrity sha512-0EMw4TTUxsMDpDkCg0rXor2gsg+npVrMIHbEhvD0HZyIhUX6AktC/yasm+qKwfyswd06Qy95ZKk8p2crTo0iPA==
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
@ -9179,6 +9205,16 @@ jws@^3.2.2:
jwa "^1.4.1"
safe-buffer "^5.0.1"
kbar@^0.1.0-beta.15:
version "0.1.0-beta.15"
resolved "https://registry.yarnpkg.com/kbar/-/kbar-0.1.0-beta.15.tgz#10122a278b3575bdb2db3c0bed62b0fc38b1175a"
integrity sha512-haAKaGZLenbONpK4FsF4FVaXudCMcNK2lzjdMM3itvMXiaaVZd5sVW/iHd7ZQ8S4T+zgXkXJE3GxCRMmvpuP7g==
dependencies:
"@reach/portal" "^0.16.0"
fast-equals "^2.0.3"
match-sorter "^6.3.0"
react-virtual "^2.8.2"
keygrip@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
@ -10071,6 +10107,14 @@ markdown-it@^12.2.0:
mdurl "^1.0.1"
uc.micro "^1.0.5"
match-sorter@^6.3.0:
version "6.3.1"
resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda"
integrity sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==
dependencies:
"@babel/runtime" "^7.12.5"
remove-accents "0.4.2"
matcher-collection@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/matcher-collection/-/matcher-collection-2.0.1.tgz#90be1a4cf58d6f2949864f65bb3b0f3e41303b29"
@ -12201,6 +12245,13 @@ react-test-renderer@^16.0.0-0:
react-is "^16.8.6"
scheduler "^0.19.1"
react-virtual@^2.8.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.8.2.tgz#e204b30c57c426bd260ed1ac49f8b1099e92b7cb"
integrity sha512-CwnvF/3Jev4M14S9S7fgzGc0UFQ/bG/VXbrUCq+AB0zH8WGnVDTG0lQT7O3jPY76YLPzTHBu+AMl64Stp8+exg==
dependencies:
"@reach/observe-rect" "^1.1.0"
react-virtualized-auto-sizer@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.5.tgz#9eeeb8302022de56fbd7a860b08513120ce36509"
@ -12515,6 +12566,11 @@ relateurl@0.2.x:
resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
remove-accents@0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
integrity sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=
remove-bom-buffer@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz#c2bf1e377520d324f623892e33c10cac2c252b53"
@ -14350,10 +14406,10 @@ tslib@^1.0.0, tslib@^1.9.0, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tsscmp@1.0.6:
version "1.0.6"