mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83361f4dbb | |||
| b5364bdc60 | |||
| 455998074c | |||
| e5e9790c59 | |||
| 6d795e5d73 | |||
| 713e27c14a | |||
| d1b28499c6 | |||
| 093158cb11 | |||
| 864e33959f | |||
| 15cecf1e53 | |||
| f3705b4a22 | |||
| 896f3700d0 | |||
| a08f433c24 | |||
| d63326066f | |||
| 1633bbf5aa | |||
| 40e84ed481 | |||
| 4fd48d9e4c | |||
| de15f901b8 | |||
| 5977fe4caa | |||
| 10cc6ed154 | |||
| da8714a4f6 | |||
| c979d003e4 | |||
| e30f6e937c | |||
| f44b5708c3 | |||
| f867704106 | |||
| b7097654b5 | |||
| d8104c6cb6 | |||
| 36f90b3a46 | |||
| 2ef827ee6f | |||
| 503598e16d | |||
| f36e18e3a6 | |||
| fd9ef3ab22 | |||
| d399e1048a | |||
| 5efeb90fdd | |||
| 31e15f798c | |||
| c1e8b6c823 | |||
| 79ba8dad30 | |||
| 85f333b2fd | |||
| 80be26b2de | |||
| 9a7090d528 | |||
| cf446be2df | |||
| 631d600920 | |||
| 8b0b383e9e | |||
| f69bcc7578 | |||
| edbcd3d4d2 | |||
| 4f0ee2c3f8 | |||
| 7e930dd1c9 | |||
| d2848c9000 | |||
| 6dab8ead8e | |||
| 03fdb846cd | |||
| 111b78ffc4 | |||
| 4c5d22084f | |||
| c2889950d5 | |||
| 5e96145277 | |||
| 4468d29740 | |||
| 3ac125d560 | |||
| 3115152dfd | |||
| eb7f8a8da0 | |||
| 21dd380d89 | |||
| 4c138ed585 | |||
| 31c84d5479 | |||
| 6cbc30172c | |||
| 7f05fe0127 | |||
| 42bf1530ac | |||
| ad2bce9c10 | |||
| ccacb65d9e | |||
| 7bb12b3f6d | |||
| 4713ea3680 | |||
| 99d233c703 | |||
| a777bbec16 | |||
| a3b8e7a65e | |||
| 4c95674ef0 | |||
| ce33a4b219 | |||
| 06ed6cfe9c | |||
| a24cb9987c | |||
| 8832808fbe | |||
| f244e864e1 | |||
| 63265b49ea |
+4
-3
@@ -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:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
REDIS_URL=redis://localhost:6479
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
@@ -36,6 +36,7 @@ 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
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
window.matchMedia = data => data;
|
||||
window.matchMedia = (data) => data;
|
||||
window.env = {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -8,6 +9,10 @@ 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,
|
||||
@@ -20,7 +25,7 @@ export const openCollection = createAction({
|
||||
// cache if the collection is renamed
|
||||
id: collection.url,
|
||||
name: collection.name,
|
||||
icon: <DynamicCollectionIcon collection={collection} />,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
perform: () => history.push(collection.url),
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ShapesIcon,
|
||||
ImportIcon,
|
||||
PinIcon,
|
||||
SearchIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
@@ -17,7 +18,7 @@ import DocumentTemplatize from "~/scenes/DocumentTemplatize";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
|
||||
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -150,10 +151,11 @@ 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 pinDocument = createAction({
|
||||
export const pinDocumentToCollection = createAction({
|
||||
name: ({ t }) => t("Pin to collection"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return false;
|
||||
@@ -188,6 +190,7 @@ 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;
|
||||
@@ -214,6 +217,13 @@ 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"),
|
||||
@@ -309,6 +319,17 @@ 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,
|
||||
@@ -319,6 +340,6 @@ export const rootDocumentActions = [
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
printDocument,
|
||||
pinDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
KeyboardIcon,
|
||||
EmailIcon,
|
||||
LogoutIcon,
|
||||
ProfileIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import {
|
||||
@@ -19,14 +20,16 @@ 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 } from "~/actions/sections";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
settingsPath,
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
homePath,
|
||||
searchUrl,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
@@ -42,14 +45,13 @@ export const navigateToHome = createAction({
|
||||
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 navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
createAction({
|
||||
section: RecentSearchesSection,
|
||||
name: searchQuery.query,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath(searchQuery.query)),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
name: ({ t }) => t("Drafts"),
|
||||
@@ -70,6 +72,7 @@ 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(),
|
||||
@@ -87,9 +90,16 @@ export const navigateToSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "s"],
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(settingsPath()),
|
||||
perform: () => history.push(organizationSettingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
name: ({ t }) => t("Profile"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
perform: () => history.push(profileSettingsPath()),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
@@ -145,12 +155,10 @@ export const logout = createAction({
|
||||
|
||||
export const rootNavigationActions = [
|
||||
navigateToHome,
|
||||
navigateToSearch,
|
||||
navigateToDrafts,
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
navigateToSettings,
|
||||
openAPIDocumentation,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
|
||||
+11
-15
@@ -1,6 +1,6 @@
|
||||
import { flattenDeep } from "lodash";
|
||||
import * as React from "react";
|
||||
import { $Diff } from "utility-types";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
@@ -10,17 +10,10 @@ import {
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
|
||||
export function createAction(
|
||||
definition: $Diff<
|
||||
Action,
|
||||
{
|
||||
id?: string;
|
||||
}
|
||||
>
|
||||
): Action {
|
||||
export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
...definition,
|
||||
id: uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,14 +41,17 @@ 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: resolvedChildren
|
||||
.map((a) => actionToMenuItem(a, context))
|
||||
.filter((a) => !!a),
|
||||
visible,
|
||||
items,
|
||||
visible: visible && items.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,7 +98,7 @@ export function actionToKBar(
|
||||
name: resolvedName,
|
||||
section: resolvedSection,
|
||||
placeholder: resolvedPlaceholder,
|
||||
keywords: `${action.keywords}`,
|
||||
keywords: action.keywords ?? "",
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
perform: action.perform
|
||||
|
||||
@@ -11,3 +11,6 @@ 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");
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
@@ -0,0 +1,51 @@
|
||||
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));
|
||||
@@ -11,11 +11,12 @@ import Sidebar from "~/components/Sidebar";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
searchUrl,
|
||||
searchPath,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
import withStores from "./withStores";
|
||||
|
||||
const DocumentHistory = React.lazy(
|
||||
@@ -49,7 +50,7 @@ class AuthenticatedLayout extends React.Component<Props> {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
history.push(searchUrl());
|
||||
history.push(searchPath());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,10 +75,12 @@ class AuthenticatedLayout extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const sidebar = showSidebar ? (
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
<Fade>
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
</Fade>
|
||||
) : undefined;
|
||||
|
||||
const rightRail = (
|
||||
|
||||
@@ -11,6 +11,7 @@ type Props = {
|
||||
icon?: React.ReactNode;
|
||||
user?: User;
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
};
|
||||
@@ -29,12 +30,13 @@ class Avatar extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src, icon, ...rest } = this.props;
|
||||
const { src, icon, showBorder, ...rest } = this.props;
|
||||
return (
|
||||
<AvatarWrapper>
|
||||
<CircleImg
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
@@ -59,12 +61,14 @@ const IconWrapper = styled.div`
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { bounceIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const Bubble = ({ count }: Props) => {
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Count>{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;
|
||||
@@ -41,7 +41,8 @@ const RealButton = styled.button<{
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
@@ -76,7 +77,8 @@ const RealButton = styled.button<{
|
||||
}
|
||||
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${
|
||||
props.borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
@@ -103,7 +105,8 @@ const RealButton = styled.button<{
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ const Container = styled.div<{ withStickyHeader?: boolean }>`
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
|
||||
padding: ${(props: any) =>
|
||||
props.withStickyHeader ? "4px 60px 60px" : "60px"};
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
|
||||
@@ -18,13 +19,13 @@ type Props = {
|
||||
};
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections, policies } = useStores();
|
||||
const { collections } = 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 = policies.abilities(collection.id);
|
||||
const can = usePolicy(collection.id);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
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";
|
||||
|
||||
export const CommandBarOptions = {
|
||||
animations: {
|
||||
enterMs: 250,
|
||||
exitMs: 200,
|
||||
},
|
||||
};
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import Text from "./Text";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
useCommandBarActions(rootActions);
|
||||
const { ui } = useStores();
|
||||
const settingsActions = useSettingsActions();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, settingsActions],
|
||||
[settingsActions]
|
||||
);
|
||||
|
||||
useCommandBarActions(commandBarActions);
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
@@ -30,20 +36,34 @@ function CommandBar() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
rootAction?.name ||
|
||||
t("Type a command or search")
|
||||
}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +79,16 @@ 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};
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -69,11 +70,17 @@ const ContentEditable = React.forwardRef(
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocus) {
|
||||
// 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) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [autoFocus, ref]);
|
||||
}, [autoFocus, disabled, isVisible, readOnly, ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== ref.current?.innerText) {
|
||||
@@ -81,6 +88,17 @@ 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
|
||||
@@ -89,6 +107,7 @@ const ContentEditable = React.forwardRef(
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
@@ -103,6 +122,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -93,11 +93,14 @@ const Spacer = styled.svg`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuAnchorCSS = css<{
|
||||
type MenuAnchorProps = {
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
}>`
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
@@ -114,6 +117,7 @@ export const MenuAnchorCSS = css<{
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
svg:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
@@ -145,6 +149,8 @@ export const MenuAnchorCSS = css<{
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -53,7 +53,7 @@ const Submenu = React.forwardRef(
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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,
|
||||
@@ -50,20 +54,42 @@ 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]);
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
previousVisible,
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
rest,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -8,7 +9,9 @@ import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
type DefaultCollectionInputSelectProps = Optional<
|
||||
React.ComponentProps<typeof InputSelect>
|
||||
> & {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
@@ -16,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
...rest
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
@@ -88,14 +92,11 @@ 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ function DocumentHistory() {
|
||||
</Header>
|
||||
<Scrollable topShadow>
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -12,12 +13,13 @@ 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 useStores from "~/hooks/useStores";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -32,7 +34,8 @@ type Props = {
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
} & CompositeStateReturn;
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
@@ -46,7 +49,6 @@ function DocumentListItem(
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
const currentTeam = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
@@ -61,17 +63,19 @@ 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 = policies.abilities(currentTeam.id);
|
||||
const canCollection = policies.abilities(document.collectionId);
|
||||
const can = usePolicy(currentTeam.id);
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
<CompositeItem
|
||||
as={DocumentLink}
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
@@ -82,6 +86,7 @@ function DocumentListItem(
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
@@ -155,7 +160,7 @@ function DocumentListItem(
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</CompositeItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +177,13 @@ const Actions = styled(EventBoundary)`
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
@@ -189,6 +201,10 @@ const DocumentLink = styled(Link)<{
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
@@ -40,6 +40,7 @@ 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);
|
||||
@@ -61,7 +62,6 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||
border={false}
|
||||
compact
|
||||
small
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,7 +21,12 @@ const SharedEditor = React.lazy(
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
|
||||
| "placeholder"
|
||||
| "defaultValue"
|
||||
| "onClickLink"
|
||||
| "embeds"
|
||||
| "dictionary"
|
||||
| "onShowToast"
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
@@ -35,7 +40,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
|
||||
const onUploadImage = React.useCallback(
|
||||
const onUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
@@ -90,7 +95,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<SharedEditor
|
||||
ref={ref}
|
||||
uploadImage={onUploadImage}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={onShowToast}
|
||||
embeds={embeds}
|
||||
dictionary={dictionary}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/* The emoji to render */
|
||||
emoji: string;
|
||||
/* The size of the emoji, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* EmojiIcon is a component that renders an emoji in the size of a standard icon
|
||||
* in a way that can be used wherever an Icon would be.
|
||||
*/
|
||||
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
|
||||
return (
|
||||
<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,13 +9,17 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import styled, { css } 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 useStores from "~/hooks/useStores";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { documentHistoryUrl } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -23,19 +27,25 @@ type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
latest?: boolean;
|
||||
};
|
||||
} & CompositeStateReturn;
|
||||
|
||||
const EventListItem = ({ event, latest, document }: Props) => {
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const location = useLocation();
|
||||
const can = policies.abilities(document.id);
|
||||
const can = usePolicy(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": {
|
||||
@@ -90,11 +100,15 @@ const EventListItem = ({ event, latest, document }: Props) => {
|
||||
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
if (document.isDeleted) {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
<BaseItem
|
||||
small
|
||||
exact
|
||||
to={document.isDeleted ? undefined : to}
|
||||
to={to}
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
@@ -102,6 +116,7 @@ const EventListItem = ({ event, latest, document }: Props) => {
|
||||
format="MMM do, h:mm a"
|
||||
relative={false}
|
||||
addSuffix
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
|
||||
@@ -116,10 +131,22 @@ const EventListItem = ({ event, latest, document }: 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;
|
||||
@@ -127,7 +154,7 @@ const Subtitle = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
const ItemStyle = css`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px;
|
||||
@@ -173,4 +200,12 @@ const ListItem = styled(Item)`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const CompositeListItem = styled(CompositeItem)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default EventListItem;
|
||||
|
||||
@@ -118,7 +118,7 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: 56px;
|
||||
min-height: 64px;
|
||||
justify-content: flex-start;
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
|
||||
@@ -5,14 +5,6 @@ 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;
|
||||
|
||||
@@ -97,7 +97,7 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
value?: string;
|
||||
label?: string;
|
||||
|
||||
@@ -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 { searchUrl } from "~/utils/routeHelpers";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input from "./Input";
|
||||
|
||||
type Props = {
|
||||
@@ -51,7 +51,7 @@ function InputSearchPage({
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
searchUrl(ev.currentTarget.value, {
|
||||
searchPath(ev.currentTarget.value, {
|
||||
collectionId,
|
||||
ref: source,
|
||||
})
|
||||
|
||||
@@ -23,6 +23,8 @@ export type Option = {
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
nude?: boolean;
|
||||
@@ -54,6 +56,7 @@ const InputSelect = (props: Props) => {
|
||||
disabled,
|
||||
note,
|
||||
icon,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const select = useSelectState({
|
||||
@@ -128,7 +131,7 @@ const InputSelect = (props: Props) => {
|
||||
wrappedLabel
|
||||
))}
|
||||
|
||||
<Select {...select} disabled={disabled} ref={buttonRef}>
|
||||
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
neutral
|
||||
@@ -229,6 +232,7 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
|
||||
@@ -40,7 +41,7 @@ function Layout({ title, children, sidebar, rightRail }: Props) {
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
{sidebar}
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
@@ -3,14 +3,13 @@ import styled, { useTheme } from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import NavLink from "~/components/NavLink";
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
image?: React.ReactNode;
|
||||
to?: string;
|
||||
exact?: boolean;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
compact?: boolean;
|
||||
border?: boolean;
|
||||
small?: boolean;
|
||||
};
|
||||
@@ -50,7 +49,7 @@ const ListItem = (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
$border={border}
|
||||
$compact={compact}
|
||||
$small={small}
|
||||
activeStyle={{
|
||||
background: theme.primary,
|
||||
}}
|
||||
@@ -64,16 +63,17 @@ const ListItem = (
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper $compact={compact} $border={border} {...rest}>
|
||||
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
|
||||
{content(false)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
|
||||
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
|
||||
display: flex;
|
||||
margin: ${(props) => (props.$compact === false ? 0 : "8px 0")};
|
||||
padding: ${(props) => (props.$compact === false ? "8px 0" : 0)};
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) =>
|
||||
props.$border === false ? (props.$small ? "8px 0" : "16px 0") : 0};
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false ? "transparent" : props.theme.divider};
|
||||
@@ -81,6 +81,8 @@ const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
cursor: ${({ to }) => (to ? "pointer" : "default")};
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import styled from "styled-components";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
|
||||
const Button = styled.button.attrs((props) => ({
|
||||
type: "type" in props ? props.type : "button",
|
||||
}))<{
|
||||
type Props = ActionButtonProps & {
|
||||
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;
|
||||
@@ -20,4 +26,4 @@ const Button = styled.button.attrs((props) => ({
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
export default Button;
|
||||
export default StyledNudeButton;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -22,23 +23,37 @@ 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) => (
|
||||
renderItem={(item, _index, compositeProps) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
showPin={!!options?.collectionId}
|
||||
{...rest}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showTemplate={showTemplate}
|
||||
showDraft={showDraft}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,16 +29,19 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
renderItem={(item, index, compositeProps) => {
|
||||
return (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...compositeProps}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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";
|
||||
@@ -18,9 +19,12 @@ type Props = WithTranslation &
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
|
||||
items: any[];
|
||||
renderItem: (arg0: any, index: number) => React.ReactNode;
|
||||
renderItem: (
|
||||
item: any,
|
||||
index: number,
|
||||
composite: CompositeStateReturn
|
||||
) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -129,44 +133,47 @@ class PaginatedList extends React.Component<Props> {
|
||||
{showList && (
|
||||
<>
|
||||
{heading}
|
||||
<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>
|
||||
<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
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
})}
|
||||
// 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} />
|
||||
|
||||
@@ -49,6 +49,7 @@ function Scrollable(
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref || fallbackRef}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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,46 +1,40 @@
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EditIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Bubble from "~/components/Bubble";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import {
|
||||
homePath,
|
||||
searchUrl,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
settingsPath,
|
||||
searchPath,
|
||||
} 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 MainSidebar() {
|
||||
function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, documents } = useStores();
|
||||
const { documents } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
@@ -55,24 +49,24 @@ function MainSidebar() {
|
||||
}),
|
||||
[dndArea]
|
||||
);
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar ref={handleSidebarRef}>
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<AccountMenu>
|
||||
<OrganizationMenu>
|
||||
{(props) => (
|
||||
<TeamButton
|
||||
<SidebarButton
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
title={team.name}
|
||||
image={
|
||||
<StyledTeamLogo src={team.avatarUrl} width={32} height={32} />
|
||||
}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Scrollable flex topShadow>
|
||||
</OrganizationMenu>
|
||||
<Scrollable flex shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
@@ -81,12 +75,7 @@ function MainSidebar() {
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: searchUrl(),
|
||||
state: {
|
||||
fromMenu: true,
|
||||
},
|
||||
}}
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
@@ -96,15 +85,19 @@ function MainSidebar() {
|
||||
to={draftsPath()}
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
</Drafts>
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Starred />
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Collections />
|
||||
</Section>
|
||||
@@ -128,12 +121,6 @@ function MainSidebar() {
|
||||
<TrashLink />
|
||||
</>
|
||||
)}
|
||||
<SidebarLink
|
||||
to={settingsPath()}
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
@@ -143,8 +130,12 @@ function MainSidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
const StyledTeamLogo = styled(TeamLogo)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export default observer(MainSidebar);
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
export default observer(AppSidebar);
|
||||
@@ -1,34 +1,19 @@
|
||||
import { groupBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
NewDocumentIcon,
|
||||
EmailIcon,
|
||||
ProfileIcon,
|
||||
PadlockIcon,
|
||||
CodeIcon,
|
||||
UserIcon,
|
||||
GroupIcon,
|
||||
LinkIcon,
|
||||
TeamIcon,
|
||||
ExpandedIcon,
|
||||
BeakerIcon,
|
||||
DownloadIcon,
|
||||
} from "outline-icons";
|
||||
import { BackIcon } 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 useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
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";
|
||||
@@ -36,124 +21,38 @@ const isHosted = env.DEPLOYMENT === "hosted";
|
||||
function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const { policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
const configs = useAuthorizedSettingsConfig();
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
const returnToDashboard = React.useCallback(() => {
|
||||
const returnToApp = React.useCallback(() => {
|
||||
history.push("/home");
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<TeamButton
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={returnToDashboard}
|
||||
<SidebarButton
|
||||
title={t("Return to App")}
|
||||
image={<StyledBackIcon color="currentColor" />}
|
||||
onClick={returnToApp}
|
||||
minHeight={48}
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
<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 && (
|
||||
<Scrollable shadow>
|
||||
{Object.keys(groupedConfig).map((header) => (
|
||||
<Section key={header}>
|
||||
<Header>{header}</Header>
|
||||
{groupedConfig[header].map((item) => (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
icon={<item.icon color="currentColor" />}
|
||||
label={item.name}
|
||||
/>
|
||||
)}
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
))}
|
||||
{!isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
@@ -165,13 +64,8 @@ function SettingsSidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
const BackIcon = styled(ExpandedIcon)`
|
||||
transform: rotate(90deg);
|
||||
margin-left: -8px;
|
||||
`;
|
||||
|
||||
const ReturnToApp = styled(Flex)`
|
||||
height: 16px;
|
||||
const StyledBackIcon = styled(BackIcon)`
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
export default observer(SettingsSidebar);
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
const { documents } = useStores();
|
||||
const { ui, documents } = useStores();
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
@@ -25,6 +25,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
shareId={shareId}
|
||||
depth={1}
|
||||
node={rootNode}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
@@ -5,16 +5,18 @@ 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;
|
||||
@@ -25,11 +27,14 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||
const collapsed = (ui.isEditing || ui.sidebarCollapsed) && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -126,7 +131,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
isFirstRender = false;
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
@@ -146,28 +150,6 @@ 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
|
||||
@@ -179,7 +161,42 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{isFirstRender ? <Fade>{content}</Fade> : content}
|
||||
{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")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
{!ui.isEditing && (
|
||||
<Toggle
|
||||
@@ -194,6 +211,10 @@ 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,7 +1,8 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop, useDrag } from "react-dnd";
|
||||
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -10,11 +11,15 @@ 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";
|
||||
@@ -65,13 +70,20 @@ function CollectionLink({
|
||||
setIsEditing(isEditing);
|
||||
}, []);
|
||||
|
||||
const { ui, documents, policies, collections } = useStores();
|
||||
const { ui, documents, 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 = policies.abilities(collection.id);
|
||||
const can = usePolicy(collection.id);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Drop to re-parent document
|
||||
@@ -105,7 +117,7 @@ function CollectionLink({
|
||||
}
|
||||
},
|
||||
canDrop: () => {
|
||||
return policies.abilities(collection.id).update;
|
||||
return can.update;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({
|
||||
@@ -118,7 +130,7 @@ function CollectionLink({
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject) => {
|
||||
drop: (item: DragObject) => {
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -131,11 +143,11 @@ function CollectionLink({
|
||||
|
||||
// Drop to reorder collection
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnotherCollection },
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
dropToReorderCollection,
|
||||
] = useDrop({
|
||||
accept: "collection",
|
||||
drop: async (item: DragObject) => {
|
||||
drop: (item: DragObject) => {
|
||||
collections.move(
|
||||
item.id,
|
||||
fractionalIndex(collection.index, belowCollectionIndex)
|
||||
@@ -147,9 +159,9 @@ function CollectionLink({
|
||||
(!belowCollection || item.id !== belowCollection.id)
|
||||
);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
|
||||
isCollectionDropping: monitor.isOver(),
|
||||
isDraggingAnotherCollection: monitor.canDrop(),
|
||||
isDraggingAnyCollection: monitor.getItemType() === "collection",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -194,8 +206,7 @@ function CollectionLink({
|
||||
collection.sort,
|
||||
]);
|
||||
|
||||
const isDraggingAnyCollection =
|
||||
isDraggingAnotherCollection || isCollectionDragging;
|
||||
const displayDocumentLinks = expanded && !isCollectionDragging;
|
||||
|
||||
React.useEffect(() => {
|
||||
// If we're viewing a starred document through the starred menu then don't
|
||||
@@ -204,21 +215,18 @@ function CollectionLink({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDraggingAnyCollection) {
|
||||
setExpanded(false);
|
||||
} else {
|
||||
setExpanded(collection.id === ui.activeCollectionId);
|
||||
if (collection.id === ui.activeCollectionId) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
|
||||
}, [collection.id, ui.activeCollectionId, search]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={drop}
|
||||
style={{
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Relative ref={drop}>
|
||||
<Draggable
|
||||
key={collection.id}
|
||||
ref={dragToReorderCollection}
|
||||
@@ -228,8 +236,16 @@ function CollectionLink({
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
to={collection.url}
|
||||
expanded={displayDocumentLinks}
|
||||
onDisclosureClick={(event) => {
|
||||
event.preventDefault();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
<CollectionIcon
|
||||
collection={collection}
|
||||
expanded={displayDocumentLinks}
|
||||
/>
|
||||
}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
@@ -242,30 +258,55 @@ function CollectionLink({
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0.5}
|
||||
depth={0}
|
||||
menu={
|
||||
!isEditing && (
|
||||
<>
|
||||
{can.update && (
|
||||
<CollectionSortMenuWithMargin
|
||||
collection={collection}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
)}
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ tooltip: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Draggable>
|
||||
{expanded && manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
</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>
|
||||
)}
|
||||
{isDraggingAnyCollection && (
|
||||
<DropCursor
|
||||
@@ -273,21 +314,8 @@ function CollectionLink({
|
||||
innerRef={dropToReorderCollection}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</Relative>
|
||||
|
||||
<Modal
|
||||
title={t("Move document")}
|
||||
onRequestClose={handlePermissionClose}
|
||||
@@ -306,13 +334,17 @@ 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,6 +1,5 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -13,9 +12,10 @@ import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
@@ -52,7 +52,10 @@ function Collections() {
|
||||
load();
|
||||
}, [collections, isFetching, showToast, fetchError, t]);
|
||||
|
||||
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
dropToReorderCollection,
|
||||
] = useDrop({
|
||||
accept: "collection",
|
||||
drop: async (item: DragObject) => {
|
||||
collections.move(
|
||||
@@ -65,16 +68,19 @@ function Collections() {
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isCollectionDropping: monitor.isOver(),
|
||||
isDraggingAnyCollection: monitor.getItemType() === "collection",
|
||||
}),
|
||||
});
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
{isDraggingAnyCollection && (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{orderedCollections.map((collection: Collection, index: number) => (
|
||||
<CollectionLink
|
||||
key={collection.id}
|
||||
@@ -85,17 +91,14 @@ function Collections() {
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
))}
|
||||
<SidebarAction action={createCollection} depth={0.5} />
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (!collections.isLoaded || fetchError) {
|
||||
return (
|
||||
<Flex column>
|
||||
<SidebarLink
|
||||
label={t("Collections")}
|
||||
icon={<Disclosure expanded={expanded} color="currentColor" />}
|
||||
/>
|
||||
<Header>{t("Collections")}</Header>
|
||||
<PlaceholderCollections />
|
||||
</Flex>
|
||||
);
|
||||
@@ -103,19 +106,18 @@ function Collections() {
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<SidebarLink
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
label={t("Collections")}
|
||||
icon={<Disclosure expanded={expanded} color="currentColor" />}
|
||||
/>
|
||||
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
|
||||
<Header onClick={() => setExpanded((prev) => !prev)} expanded={expanded}>
|
||||
{t("Collections")}
|
||||
</Header>
|
||||
{expanded && (
|
||||
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default observer(Collections);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
export default Disclosure;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = {
|
||||
onClick?: React.MouseEventHandler<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,12 +11,13 @@ 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";
|
||||
@@ -48,6 +49,7 @@ function DocumentLink(
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { showToast } = useToasts();
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
@@ -81,7 +83,9 @@ function DocumentLink(
|
||||
isActiveDocument)
|
||||
);
|
||||
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
const [openedOnce, setOpenedOnce] = React.useState(expanded);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
@@ -89,6 +93,12 @@ function DocumentLink(
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded) {
|
||||
setOpenedOnce(true);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// when the last child document is removed,
|
||||
// also close the local folder state to closed
|
||||
React.useEffect(() => {
|
||||
@@ -98,7 +108,7 @@ function DocumentLink(
|
||||
}, [expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
@@ -218,6 +228,19 @@ 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;
|
||||
}
|
||||
@@ -270,6 +293,8 @@ function DocumentLink(
|
||||
t("Untitled");
|
||||
|
||||
const can = policies.abilities(node.id);
|
||||
const isExpanded = expanded && !isDragging;
|
||||
const hasChildren = nodeChildren.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -283,6 +308,8 @@ function DocumentLink(
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
@@ -291,21 +318,13 @@ function DocumentLink(
|
||||
},
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded && !isDragging}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
</>
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
}
|
||||
isActive={(match, location) =>
|
||||
!!match && location.search !== "?starred"
|
||||
@@ -324,16 +343,18 @@ function DocumentLink(
|
||||
!isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
})}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<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>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
@@ -347,30 +368,40 @@ function DocumentLink(
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{manualSort && isDraggingAnyDocument && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
{isDraggingAnyDocument && (
|
||||
<DropCursor
|
||||
disabled={!manualSort}
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
/>
|
||||
)}
|
||||
</Relative>
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Folder = styled.div<{ $open?: boolean }>`
|
||||
display: ${(props) => (props.$open ? "block" : "none")};
|
||||
`;
|
||||
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
function DropCursor({
|
||||
isActiveDrop,
|
||||
innerRef,
|
||||
position,
|
||||
}: {
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
isActiveDrop: boolean;
|
||||
innerRef: React.Ref<HTMLDivElement>;
|
||||
position?: "top";
|
||||
}) {
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} position={position} />;
|
||||
};
|
||||
|
||||
function DropCursor({ isActiveDrop, innerRef, position, disabled }: Props) {
|
||||
return (
|
||||
<Cursor
|
||||
isOver={isActiveDrop}
|
||||
disabled={disabled}
|
||||
ref={innerRef}
|
||||
position={position}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// transparent hover zone with a thin visible band vertically centered
|
||||
const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
|
||||
const Cursor = styled.div<{
|
||||
isOver?: boolean;
|
||||
disabled?: boolean;
|
||||
position?: "top";
|
||||
}>`
|
||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||
transition: opacity 150ms;
|
||||
position: absolute;
|
||||
@@ -23,10 +33,13 @@ const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
background: transparent;
|
||||
${(props) => (props.position === "top" ? "top: 25px;" : "bottom: -7px;")}
|
||||
${(props) => (props.position === "top" ? "top: -7px;" : "bottom: -7px;")}
|
||||
|
||||
::after {
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
background: ${(props) =>
|
||||
props.disabled
|
||||
? props.theme.sidebarActiveBackground
|
||||
: props.theme.slateDark};
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
content: "";
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
|
||||
@@ -19,7 +20,7 @@ type Props = {
|
||||
|
||||
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies } = useStores();
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { handleFiles, isImporting } = useImportDocument(
|
||||
collectionId,
|
||||
@@ -28,7 +29,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
const targetId = collectionId || documentId;
|
||||
invariant(targetId, "Must provide either collectionId or documentId");
|
||||
|
||||
const can = policies.abilities(targetId);
|
||||
const can = usePolicy(targetId);
|
||||
const handleRejection = React.useCallback(() => {
|
||||
showToast(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Header = styled(Flex)`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 4px 12px;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick?: React.MouseEventHandler;
|
||||
expanded?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Header({ onClick, expanded, children }: Props) {
|
||||
return (
|
||||
<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,8 +13,7 @@ function PlaceholderCollections() {
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin: 4px 16px;
|
||||
margin-left: 40px;
|
||||
margin: 4px 12px;
|
||||
width: 75%;
|
||||
`;
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import SidebarLink from "./SidebarLink";
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
collection?: Collection;
|
||||
activeDocument: Document | null | undefined;
|
||||
activeDocumentId: string | undefined;
|
||||
activeDocument: Document | undefined;
|
||||
isDraft?: boolean;
|
||||
depth: number;
|
||||
index: number;
|
||||
@@ -20,13 +21,21 @@ type Props = {
|
||||
};
|
||||
|
||||
function DocumentLink(
|
||||
{ node, collection, activeDocument, isDraft, depth, shareId }: Props,
|
||||
{
|
||||
node,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentId,
|
||||
isDraft,
|
||||
depth,
|
||||
shareId,
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const isActiveDocument = activeDocumentId === node.id;
|
||||
|
||||
const hasChildDocuments =
|
||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||
@@ -112,6 +121,7 @@ function DocumentLink(
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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,11 +19,14 @@ 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;
|
||||
@@ -50,6 +53,8 @@ function SidebarLink(
|
||||
href,
|
||||
depth,
|
||||
className,
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -66,10 +71,10 @@ function SidebarLink(
|
||||
() => ({
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
background: theme.sidebarActiveBackground,
|
||||
...style,
|
||||
}),
|
||||
[theme, style]
|
||||
[theme.text, theme.sidebarActiveBackground, style]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -90,14 +95,34 @@ function SidebarLink(
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
<Content>
|
||||
{expanded !== undefined && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onClick={onDisclosureClick}
|
||||
root={depth === 0}
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
</Content>
|
||||
</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;
|
||||
@@ -108,12 +133,15 @@ export const IconWrapper = styled.span`
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
|
||||
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
|
||||
display: inline-flex;
|
||||
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
gap: 4px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
transition: opacity 50ms;
|
||||
height: 24px;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
@@ -122,7 +150,7 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
|
||||
}
|
||||
|
||||
&:hover {
|
||||
display: inline-flex;
|
||||
visibility: visible;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
@@ -158,29 +186,25 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
|
||||
transition: fill 50ms;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) =>
|
||||
transparentize("0.25", props.theme.sidebarItemBackground)};
|
||||
&:hover svg {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
& + ${Actions} {
|
||||
${NudeButton} {
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
}
|
||||
}
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
|
||||
&:focus + ${Actions} {
|
||||
${NudeButton} {
|
||||
background: ${(props) =>
|
||||
transparentize("0.25", props.theme.sidebarItemBackground)};
|
||||
background: transparent;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="page"] + ${Actions} {
|
||||
${NudeButton} {
|
||||
background: ${(props) => props.theme.sidebarItemBackground};
|
||||
}
|
||||
background: ${(props) => props.theme.sidebarActiveBackground};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -190,7 +214,7 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover + ${Actions}, &:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
visibility: visible;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
@@ -202,6 +226,12 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
@@ -209,6 +239,7 @@ const Label = styled.div`
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 1.6;
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -10,8 +9,8 @@ import Flex from "~/components/Flex";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Section from "./Section";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
|
||||
@@ -119,71 +118,64 @@ function Starred() {
|
||||
}),
|
||||
});
|
||||
|
||||
const content = stars.orderedData.slice(0, upperBound).map((star) => {
|
||||
const document = documents.get(star.documentId);
|
||||
|
||||
return document ? (
|
||||
<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 (
|
||||
<Section>
|
||||
<Flex column>
|
||||
<SidebarLink
|
||||
onClick={handleExpandClick}
|
||||
label={t("Starred")}
|
||||
icon={<Disclosure expanded={expanded} color="currentColor" />}
|
||||
/>
|
||||
{expanded && (
|
||||
<>
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
<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}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
{show === "More" && !isFetching && (
|
||||
<SidebarLink
|
||||
onClick={handleShowMore}
|
||||
label={`${t("Show more")}…`}
|
||||
depth={0}
|
||||
/>
|
||||
{content}
|
||||
{show === "More" && !isFetching && (
|
||||
<SidebarLink
|
||||
onClick={handleShowMore}
|
||||
label={`${t("Show more")}…`}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
{show === "Less" && !isFetching && (
|
||||
<SidebarLink
|
||||
onClick={handleShowLess}
|
||||
label={`${t("Show less")}…`}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
{(isFetching || fetchError) && !stars.orderedData.length && (
|
||||
<Flex column>
|
||||
<PlaceholderCollections />
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Section>
|
||||
)}
|
||||
{show === "Less" && !isFetching && (
|
||||
<SidebarLink
|
||||
onClick={handleShowLess}
|
||||
label={`${t("Show less")}…`}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{(isFetching || fetchError) && !stars.orderedData.length && (
|
||||
<Flex column>
|
||||
<PlaceholderCollections />
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default observer(Starred);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import Star from "~/models/Star";
|
||||
import EmojiIcon from "~/components/EmojiIcon";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import Disclosure from "./Disclosure";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -27,24 +26,22 @@ type Props = {
|
||||
|
||||
function StarredLink({
|
||||
depth,
|
||||
title,
|
||||
to,
|
||||
documentId,
|
||||
title,
|
||||
collectionId,
|
||||
star,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections, documents, policies } = useStores();
|
||||
const theme = useTheme();
|
||||
const { collections, documents } = useStores();
|
||||
const collection = collections.get(collectionId);
|
||||
const document = documents.get(documentId);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const canUpdate = policies.abilities(documentId).update;
|
||||
const childDocuments = collection
|
||||
? collection.getDocumentChildren(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -57,7 +54,7 @@ function StarredLink({
|
||||
}, [collection, collectionId, collections, document, documentId, documents]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<SVGElement>) => {
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
@@ -65,29 +62,6 @@ function StarredLink({
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (title: string) => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
await documents.update(
|
||||
{
|
||||
id: document.id,
|
||||
text: document.text,
|
||||
title,
|
||||
},
|
||||
{
|
||||
lastRevision: document.revision,
|
||||
}
|
||||
);
|
||||
},
|
||||
[documents, document]
|
||||
);
|
||||
|
||||
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
|
||||
setIsEditing(isEditing);
|
||||
}, []);
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: "star",
|
||||
@@ -96,7 +70,7 @@ function StarredLink({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => {
|
||||
return depth === 2;
|
||||
return depth === 0;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,36 +90,34 @@ function StarredLink({
|
||||
}),
|
||||
});
|
||||
|
||||
const { emoji } = parseTitle(title);
|
||||
const label = emoji ? title.replace(emoji, "") : title;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onClick={handleDisclosureClick}
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={title || t("Untitled")}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
label={depth === 0 ? label : title}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isEditing ? (
|
||||
document ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
@@ -164,7 +136,7 @@ function StarredLink({
|
||||
childDocuments.map((childDocument) => (
|
||||
<ObserveredStarredLink
|
||||
key={childDocument.id}
|
||||
depth={depth + 1}
|
||||
depth={depth === 0 ? 2 : depth + 1}
|
||||
title={childDocument.title}
|
||||
to={childDocument.url}
|
||||
documentId={childDocument.id}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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,3 +1,3 @@
|
||||
import Sidebar from "./Main";
|
||||
import Sidebar from "./App";
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
+7
-18
@@ -1,8 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -12,22 +13,10 @@ type Props = {
|
||||
};
|
||||
|
||||
function Star({ size, document, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (document.isStarred) {
|
||||
document.unstar();
|
||||
} else {
|
||||
document.star();
|
||||
}
|
||||
},
|
||||
[document]
|
||||
);
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
@@ -35,9 +24,9 @@ function Star({ size, document, ...rest }: Props) {
|
||||
|
||||
return (
|
||||
<NudeButton
|
||||
onClick={handleClick}
|
||||
context={context}
|
||||
action={document.isStarred ? unstarDocument : starDocument}
|
||||
size={size}
|
||||
aria-label={document.isStarred ? t("Unstar") : t("Star")}
|
||||
{...rest}
|
||||
>
|
||||
{document.isStarred ? (
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -50,12 +49,9 @@ function Switch({
|
||||
<InlineLabelText>{label}</InlineLabelText>
|
||||
</Label>
|
||||
{note && (
|
||||
<Flex>
|
||||
<Input width={width} height={height} aria-hidden="true" />
|
||||
<Text type="secondary" size="small">
|
||||
{note}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Text type="secondary" size="small">
|
||||
{note}
|
||||
</Text>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
+43
-15
@@ -6,8 +6,10 @@ 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 = {
|
||||
@@ -121,7 +123,11 @@ function Table({
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
|
||||
<SortWrapper align="center" gap={4}>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
@@ -190,17 +196,19 @@ export const Placeholder = ({
|
||||
rows?: number;
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
</DelayedMount>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -214,6 +222,8 @@ const Pagination = styled(Flex)`
|
||||
`;
|
||||
|
||||
const DescSortIcon = styled(CollapsedIcon)`
|
||||
margin-left: -2px;
|
||||
|
||||
&:hover {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
@@ -229,12 +239,23 @@ const InnerTable = styled.table`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SortWrapper = styled(Flex)`
|
||||
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
display: inline-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: 6px;
|
||||
padding: 10px 6px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
font-size: 14px;
|
||||
|
||||
@@ -248,6 +269,13 @@ const Cell = styled.td`
|
||||
text-align: right;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Row = styled.tr`
|
||||
@@ -270,7 +298,7 @@ const Head = styled.th`
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 54px;
|
||||
padding: 6px;
|
||||
padding: 6px 6px 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
@@ -6,7 +6,7 @@ const TeamLogo = styled.img<{ width?: number; height?: number; size?: string }>`
|
||||
height: ${(props) =>
|
||||
props.height ? `${props.height}px` : props.size || "38px"};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background};
|
||||
background: white;
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -2,7 +2,7 @@ import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type?: "secondary" | "tertiary";
|
||||
size?: "small" | "xsmall";
|
||||
size?: "large" | "small" | "xsmall";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,7 +18,9 @@ const Text = styled.p<Props>`
|
||||
? props.theme.textTertiary
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "small"
|
||||
props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
|
||||
+13
-9
@@ -9,9 +9,11 @@ const LocaleTime = React.lazy(
|
||||
)
|
||||
);
|
||||
|
||||
type Props = React.ComponentProps<typeof LocaleTime>;
|
||||
type Props = React.ComponentProps<typeof LocaleTime> & {
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function Time(props: Props) {
|
||||
function Time({ onClick, ...props }: Props) {
|
||||
let content = formatDistanceToNow(Date.parse(props.dateTime), {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
@@ -24,13 +26,15 @@ function Time(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime tooltipDelay={250} {...props} />
|
||||
</React.Suspense>
|
||||
<span onClick={onClick}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime tooltipDelay={250} {...props} />
|
||||
</React.Suspense>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TFunctionResult } from "i18next";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
tooltip: React.ReactChild | React.ReactChild[] | TFunctionResult;
|
||||
shortcut?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -27,10 +27,10 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
dictionary: Dictionary;
|
||||
view: EditorView;
|
||||
search: string;
|
||||
uploadImage?: (file: File) => Promise<string>;
|
||||
onImageUploadStart?: () => void;
|
||||
onImageUploadStop?: () => void;
|
||||
onShowToast?: (message: string, id: string) => void;
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
onFileUploadStop?: () => void;
|
||||
onShowToast: (message: string, id: string) => void;
|
||||
onLinkToolbarOpen?: () => void;
|
||||
onClose: () => void;
|
||||
onClearSearch: () => void;
|
||||
@@ -178,7 +178,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
insertItem = (item: any) => {
|
||||
switch (item.name) {
|
||||
case "image":
|
||||
return this.triggerImagePick();
|
||||
return this.triggerFilePick("image/*");
|
||||
case "attachment":
|
||||
return this.triggerFilePick("*");
|
||||
case "embed":
|
||||
return this.triggerLinkInput(item);
|
||||
case "link": {
|
||||
@@ -212,7 +214,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 && this.props.onShowToast) {
|
||||
if (!matches) {
|
||||
this.props.onShowToast(
|
||||
this.props.dictionary.embedInvalidLink,
|
||||
ToastType.Error
|
||||
@@ -258,8 +260,11 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
}
|
||||
};
|
||||
|
||||
triggerImagePick = () => {
|
||||
triggerFilePick = (accept: string) => {
|
||||
if (this.inputRef.current) {
|
||||
if (accept) {
|
||||
this.inputRef.current.accept = accept;
|
||||
}
|
||||
this.inputRef.current.click();
|
||||
}
|
||||
};
|
||||
@@ -268,14 +273,14 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
this.setState({ insertItem: item });
|
||||
};
|
||||
|
||||
handleImagePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
|
||||
const {
|
||||
view,
|
||||
uploadImage,
|
||||
onImageUploadStart,
|
||||
onImageUploadStop,
|
||||
uploadFile,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
onShowToast,
|
||||
} = this.props;
|
||||
const { state } = view;
|
||||
@@ -283,17 +288,18 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
|
||||
this.clearSearch();
|
||||
|
||||
if (!uploadImage) {
|
||||
throw new Error("uploadImage prop is required to replace images");
|
||||
if (!uploadFile) {
|
||||
throw new Error("uploadFile prop is required to replace files");
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
insertFiles(view, event, parent.pos, files, {
|
||||
uploadImage,
|
||||
onImageUploadStart,
|
||||
onImageUploadStop,
|
||||
uploadFile,
|
||||
onFileUploadStart,
|
||||
onFileUploadStop,
|
||||
onShowToast,
|
||||
dictionary: this.props.dictionary,
|
||||
isAttachment: this.inputRef.current?.accept === "*",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -409,7 +415,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
const {
|
||||
embeds = [],
|
||||
search = "",
|
||||
uploadImage,
|
||||
uploadFile,
|
||||
commands,
|
||||
filterable = true,
|
||||
} = this.props;
|
||||
@@ -447,7 +453,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 (!uploadImage && item.name === "image") {
|
||||
if (!uploadFile && item.name === "image") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -470,7 +476,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dictionary, isActive, uploadImage } = this.props;
|
||||
const { dictionary, isActive, uploadFile } = this.props;
|
||||
const items = this.filtered;
|
||||
const { insertItem, ...positioning } = this.state;
|
||||
|
||||
@@ -537,13 +543,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
{uploadImage && (
|
||||
{uploadFile && (
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
ref={this.inputRef}
|
||||
onChange={this.handleImagePicked}
|
||||
accept="image/*"
|
||||
onChange={this.handleFilePicked}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+20
-13
@@ -28,6 +28,7 @@ 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";
|
||||
@@ -107,7 +108,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 */
|
||||
uploadImage?: (file: File) => Promise<string>;
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
/** Callback when editor is blurred, as native input */
|
||||
onBlur?: () => void;
|
||||
/** Callback when editor is focused, as native input */
|
||||
@@ -119,9 +120,9 @@ export type Props = {
|
||||
/** Callback when user changes editor content */
|
||||
onChange?: (value: () => string) => void;
|
||||
/** Callback when a file upload begins */
|
||||
onImageUploadStart?: () => void;
|
||||
onFileUploadStart?: () => void;
|
||||
/** Callback when a file upload ends */
|
||||
onImageUploadStop?: () => void;
|
||||
onFileUploadStop?: () => 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 */
|
||||
@@ -142,7 +143,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;
|
||||
};
|
||||
@@ -177,10 +178,10 @@ export class Editor extends React.PureComponent<
|
||||
defaultValue: "",
|
||||
dir: "auto",
|
||||
placeholder: "Write something nice…",
|
||||
onImageUploadStart: () => {
|
||||
onFileUploadStart: () => {
|
||||
// no default behavior
|
||||
},
|
||||
onImageUploadStop: () => {
|
||||
onFileUploadStop: () => {
|
||||
// no default behavior
|
||||
},
|
||||
embeds: [],
|
||||
@@ -318,7 +319,8 @@ export class Editor extends React.PureComponent<
|
||||
createExtensions() {
|
||||
const { dictionary } = this.props;
|
||||
|
||||
// adding nodes here? Update schema.ts for serialization on the server
|
||||
// adding nodes here? Update server/editor/renderToHtml.ts for serialization
|
||||
// on the server
|
||||
return new ExtensionManager(
|
||||
[
|
||||
...[
|
||||
@@ -341,6 +343,9 @@ export class Editor extends React.PureComponent<
|
||||
new BulletList(),
|
||||
new Embed({ embeds: this.props.embeds }),
|
||||
new ListItem(),
|
||||
new Attachment({
|
||||
dictionary,
|
||||
}),
|
||||
new Notice({
|
||||
dictionary,
|
||||
}),
|
||||
@@ -351,9 +356,9 @@ export class Editor extends React.PureComponent<
|
||||
new HorizontalRule(),
|
||||
new Image({
|
||||
dictionary,
|
||||
uploadImage: this.props.uploadImage,
|
||||
onImageUploadStart: this.props.onImageUploadStart,
|
||||
onImageUploadStop: this.props.onImageUploadStop,
|
||||
uploadFile: this.props.uploadFile,
|
||||
onFileUploadStart: this.props.onFileUploadStart,
|
||||
onFileUploadStop: this.props.onFileUploadStop,
|
||||
onShowToast: this.props.onShowToast,
|
||||
}),
|
||||
new Table(),
|
||||
@@ -779,6 +784,7 @@ 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}
|
||||
@@ -795,6 +801,7 @@ 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 })}
|
||||
@@ -807,10 +814,10 @@ export class Editor extends React.PureComponent<
|
||||
isActive={this.state.blockMenuOpen}
|
||||
search={this.state.blockMenuSearch}
|
||||
onClose={this.handleCloseBlockMenu}
|
||||
uploadImage={this.props.uploadImage}
|
||||
uploadFile={this.props.uploadFile}
|
||||
onLinkToolbarOpen={this.handleOpenLinkMenu}
|
||||
onImageUploadStart={this.props.onImageUploadStart}
|
||||
onImageUploadStop={this.props.onImageUploadStop}
|
||||
onFileUploadStart={this.props.onFileUploadStart}
|
||||
onFileUploadStop={this.props.onFileUploadStop}
|
||||
onShowToast={this.props.onShowToast}
|
||||
embeds={this.props.embeds}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
WarningIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
AttachmentIcon,
|
||||
} from "outline-icons";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -84,6 +85,12 @@ 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,
|
||||
@@ -124,21 +131,21 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
name: "container_notice",
|
||||
title: dictionary.infoNotice,
|
||||
icon: InfoIcon,
|
||||
keywords: "container_notice card information",
|
||||
keywords: "notice card information",
|
||||
attrs: { style: "info" },
|
||||
},
|
||||
{
|
||||
name: "container_notice",
|
||||
title: dictionary.warningNotice,
|
||||
icon: WarningIcon,
|
||||
keywords: "container_notice card error",
|
||||
keywords: "notice card error",
|
||||
attrs: { style: "warning" },
|
||||
},
|
||||
{
|
||||
name: "container_notice",
|
||||
title: dictionary.tipNotice,
|
||||
icon: StarredIcon,
|
||||
keywords: "container_notice card suggestion",
|
||||
keywords: "notice card suggestion",
|
||||
attrs: { style: "tip" },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function useActionContext(
|
||||
return {
|
||||
isContextMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId,
|
||||
activeDocumentId: stores.ui.activeDocumentId,
|
||||
currentUserId: stores.auth.user?.id,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
@@ -11,7 +11,10 @@ import useActionContext from "./useActionContext";
|
||||
*
|
||||
* @param actions actions to make available
|
||||
*/
|
||||
export default function useCommandBarActions(actions: Action[]) {
|
||||
export default function useCommandBarActions(
|
||||
actions: Action[],
|
||||
additionalDeps: React.DependencyList = []
|
||||
) {
|
||||
const location = useLocation();
|
||||
const context = useActionContext({
|
||||
isCommandBar: true,
|
||||
@@ -24,5 +27,6 @@ export default function useCommandBarActions(actions: Action[]) {
|
||||
useRegisterActions(registerable, [
|
||||
registerable.map((r) => r.id).join(""),
|
||||
location.pathname,
|
||||
...additionalDeps,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function useDictionary() {
|
||||
alignImageDefault: t("Center large"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
file: t("File attachment"),
|
||||
findOrCreateDoc: `${t("Find or create a doc")}…`,
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
@@ -39,7 +40,7 @@ export default function useDictionary() {
|
||||
heading: t("Heading"),
|
||||
hr: t("Divider"),
|
||||
image: t("Image"),
|
||||
imageUploadError: t("Sorry, an error occurred uploading the image"),
|
||||
fileUploadError: t("Sorry, an error occurred uploading the file"),
|
||||
imageCaptionPlaceholder: t("Write a caption"),
|
||||
info: t("Info"),
|
||||
infoNotice: t("Info notice"),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
@@ -0,0 +1,34 @@
|
||||
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,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);
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
+11
-2
@@ -8,7 +8,6 @@ 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";
|
||||
@@ -53,6 +52,16 @@ 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>
|
||||
@@ -60,7 +69,7 @@ if (element) {
|
||||
<Analytics>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<KBarProvider actions={[]} options={CommandBarOptions}>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
|
||||
@@ -2,13 +2,10 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { createAction } from "~/actions";
|
||||
import { development } from "~/actions/definitions/debug";
|
||||
import {
|
||||
navigateToSettings,
|
||||
navigateToProfileSettings,
|
||||
openKeyboardShortcuts,
|
||||
openChangelog,
|
||||
openAPIDocumentation,
|
||||
@@ -17,9 +14,7 @@ import {
|
||||
logout,
|
||||
} from "~/actions/definitions/navigation";
|
||||
import { changeTheme } from "~/actions/definitions/settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useSessions from "~/hooks/useSessions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import separator from "~/menus/separator";
|
||||
|
||||
@@ -28,15 +23,12 @@ type Props = {
|
||||
};
|
||||
|
||||
function AccountMenu(props: Props) {
|
||||
const [sessions] = useSessions();
|
||||
const menu = useMenuState({
|
||||
unstable_offset: [8, 0],
|
||||
placement: "bottom-start",
|
||||
placement: "bottom-end",
|
||||
modal: true,
|
||||
});
|
||||
const { ui } = useStores();
|
||||
const { theme } = ui;
|
||||
const team = useCurrentTeam();
|
||||
const previousTheme = usePrevious(theme);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -47,39 +39,19 @@ function AccountMenu(props: Props) {
|
||||
}, [menu, theme, previousTheme]);
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== team.id && session.url !== team.url
|
||||
);
|
||||
|
||||
return [
|
||||
navigateToSettings,
|
||||
openKeyboardShortcuts,
|
||||
openAPIDocumentation,
|
||||
separator(),
|
||||
openChangelog,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
development,
|
||||
changeTheme,
|
||||
navigateToProfileSettings,
|
||||
separator(),
|
||||
...(otherSessions.length
|
||||
? [
|
||||
createAction({
|
||||
name: t("Switch team"),
|
||||
section: "account",
|
||||
children: otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
section: "account",
|
||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
logout,
|
||||
];
|
||||
}, [team.id, team.url, sessions, t]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -91,10 +63,4 @@ function AccountMenu(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const Logo = styled("img")`
|
||||
border-radius: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default observer(AccountMenu);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ImportIcon,
|
||||
ExportIcon,
|
||||
PadlockIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ManualSortIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -23,6 +25,7 @@ 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";
|
||||
@@ -51,7 +54,7 @@ function CollectionMenu({
|
||||
});
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const team = useCurrentTeam();
|
||||
const { documents, policies } = useStores();
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
@@ -123,8 +126,22 @@ function CollectionMenu({
|
||||
[history, showToast, collection.id, documents]
|
||||
);
|
||||
|
||||
const can = policies.abilities(collection.id);
|
||||
const canUserInTeam = policies.abilities(team.id);
|
||||
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 items: MenuItem[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -144,6 +161,30 @@ 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")}…`,
|
||||
@@ -181,6 +222,8 @@ function CollectionMenu({
|
||||
t,
|
||||
can.update,
|
||||
can.delete,
|
||||
alphabeticalSort,
|
||||
handleChangeSort,
|
||||
handleNewDocument,
|
||||
handleImportDocument,
|
||||
collection,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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);
|
||||
@@ -40,12 +40,10 @@ import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import Switch from "~/components/Switch";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
pinDocument,
|
||||
pinDocumentToHome,
|
||||
} from "~/actions/definitions/documents";
|
||||
import { pinDocument } 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";
|
||||
@@ -177,7 +175,7 @@ function DocumentMenu({
|
||||
);
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = policies.abilities(document.id);
|
||||
const can = usePolicy(document.id);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
@@ -328,7 +326,6 @@ function DocumentMenu({
|
||||
visible: !document.isStarred && !!can.star,
|
||||
icon: <StarredIcon />,
|
||||
},
|
||||
actionToMenuItem(pinDocumentToHome, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
|
||||
@@ -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 useStores from "~/hooks/useStores";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -19,13 +19,12 @@ 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 = policies.abilities(group.id);
|
||||
const can = usePolicy(group.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,16 +4,22 @@ 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,
|
||||
@@ -21,7 +27,7 @@ function NewDocumentMenu() {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
const can = usePolicy(team.id);
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
@@ -32,7 +38,7 @@ function NewDocumentMenu() {
|
||||
type: "route",
|
||||
to: newDocumentPath(collection.id),
|
||||
title: <CollectionName>{collection.name}</CollectionName>,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -21,7 +22,7 @@ function NewTemplateMenu() {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { createAction } from "~/actions";
|
||||
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useSessions from "~/hooks/useSessions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import separator from "~/menus/separator";
|
||||
|
||||
type Props = {
|
||||
children: (props: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
function OrganizationMenu(props: Props) {
|
||||
const [sessions] = useSessions();
|
||||
const menu = useMenuState({
|
||||
unstable_offset: [4, -4],
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
});
|
||||
const { ui } = useStores();
|
||||
const { theme } = ui;
|
||||
const team = useCurrentTeam();
|
||||
const previousTheme = usePrevious(theme);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (theme !== previousTheme) {
|
||||
menu.hide();
|
||||
}
|
||||
}, [menu, theme, previousTheme]);
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== team.id && session.url !== team.url
|
||||
);
|
||||
|
||||
return [
|
||||
navigateToSettings,
|
||||
separator(),
|
||||
...(otherSessions.length
|
||||
? [
|
||||
createAction({
|
||||
name: t("Switch team"),
|
||||
section: "account",
|
||||
children: otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
section: "account",
|
||||
icon: <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);
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
|
||||
@@ -20,11 +21,11 @@ function ShareMenu({ share }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { shares, policies } = useStores();
|
||||
const { shares } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const can = policies.abilities(share.id);
|
||||
const can = usePolicy(share.id);
|
||||
|
||||
const handleGoToDocument = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 = {
|
||||
@@ -13,12 +14,12 @@ type Props = {
|
||||
};
|
||||
|
||||
function UserMenu({ user }: Props) {
|
||||
const { users, policies } = useStores();
|
||||
const { users } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const can = policies.abilities(user.id);
|
||||
const can = usePolicy(user.id);
|
||||
|
||||
const handlePromote = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from "mobx";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import BaseModal from "./BaseModel";
|
||||
import User from "./User";
|
||||
|
||||
@@ -23,13 +24,7 @@ class FileOperation extends BaseModal {
|
||||
|
||||
@computed
|
||||
get sizeInMB(): string {
|
||||
const inKB = this.size / 1024;
|
||||
|
||||
if (inKB < 1024) {
|
||||
return inKB.toFixed(2) + "KB";
|
||||
}
|
||||
|
||||
return (inKB / 1024).toFixed(2) + "MB";
|
||||
return bytesToHumanReadable(this.size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
} from "y-prosemirror";
|
||||
} from "@getoutline/y-prosemirror";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
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
Reference in New Issue
Block a user