Merge branch 'main' into feat/mass-import
This commit is contained in:
@ -3,11 +3,15 @@ import * as React from "react";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { bounceIn } from "shared/styles/animations";
|
import { bounceIn } from "shared/styles/animations";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
count: number,
|
count: number,
|
||||||
};
|
|};
|
||||||
|
|
||||||
const Bubble = ({ count }: Props) => {
|
const Bubble = ({ count }: Props) => {
|
||||||
|
if (!count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <Count>{count}</Count>;
|
return <Count>{count}</Count>;
|
||||||
};
|
};
|
||||||
|
|
@ -76,7 +76,6 @@ class Layout extends React.Component<Props> {
|
|||||||
|
|
||||||
@keydown("shift+/")
|
@keydown("shift+/")
|
||||||
handleOpenKeyboardShortcuts() {
|
handleOpenKeyboardShortcuts() {
|
||||||
if (this.props.ui.editMode) return;
|
|
||||||
this.keyboardShortcutsOpen = true;
|
this.keyboardShortcutsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,7 +85,6 @@ class Layout extends React.Component<Props> {
|
|||||||
|
|
||||||
@keydown(["t", "/", `${meta}+k`])
|
@keydown(["t", "/", `${meta}+k`])
|
||||||
goToSearch(ev: SyntheticEvent<>) {
|
goToSearch(ev: SyntheticEvent<>) {
|
||||||
if (this.props.ui.editMode) return;
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
this.redirectTo = searchUrl();
|
this.redirectTo = searchUrl();
|
||||||
@ -94,7 +92,6 @@ class Layout extends React.Component<Props> {
|
|||||||
|
|
||||||
@keydown("d")
|
@keydown("d")
|
||||||
goToDashboard() {
|
goToDashboard() {
|
||||||
if (this.props.ui.editMode) return;
|
|
||||||
this.redirectTo = homeUrl();
|
this.redirectTo = homeUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +99,7 @@ class Layout extends React.Component<Props> {
|
|||||||
const { auth, t, ui } = this.props;
|
const { auth, t, ui } = this.props;
|
||||||
const { user, team } = auth;
|
const { user, team } = auth;
|
||||||
const showSidebar = auth.authenticated && user && team;
|
const showSidebar = auth.authenticated && user && team;
|
||||||
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||||
|
|
||||||
if (auth.isSuspended) return <ErrorSuspended />;
|
if (auth.isSuspended) return <ErrorSuspended />;
|
||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||||
|
@ -16,11 +16,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import CollectionNew from "scenes/CollectionNew";
|
import CollectionNew from "scenes/CollectionNew";
|
||||||
import Invite from "scenes/Invite";
|
import Invite from "scenes/Invite";
|
||||||
|
import Bubble from "components/Bubble";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import Modal from "components/Modal";
|
import Modal from "components/Modal";
|
||||||
import Scrollable from "components/Scrollable";
|
import Scrollable from "components/Scrollable";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
import Bubble from "./components/Bubble";
|
|
||||||
import Collections from "./components/Collections";
|
import Collections from "./components/Collections";
|
||||||
import HeaderBlock from "./components/HeaderBlock";
|
import HeaderBlock from "./components/HeaderBlock";
|
||||||
import Section from "./components/Section";
|
import Section from "./components/Section";
|
||||||
@ -118,9 +118,7 @@ function MainSidebar() {
|
|||||||
label={
|
label={
|
||||||
<Drafts align="center">
|
<Drafts align="center">
|
||||||
{t("Drafts")}
|
{t("Drafts")}
|
||||||
{documents.totalDrafts > 0 && (
|
|
||||||
<Bubble count={documents.totalDrafts} />
|
<Bubble count={documents.totalDrafts} />
|
||||||
)}
|
|
||||||
</Drafts>
|
</Drafts>
|
||||||
}
|
}
|
||||||
active={
|
active={
|
||||||
|
@ -3,29 +3,37 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Portal } from "react-portal";
|
import { Portal } from "react-portal";
|
||||||
import { withRouter } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import type { Location } from "react-router-dom";
|
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import Fade from "components/Fade";
|
import Fade from "components/Fade";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import CollapseToggle, {
|
|
||||||
Button as CollapseButton,
|
|
||||||
} from "./components/CollapseToggle";
|
|
||||||
import ResizeBorder from "./components/ResizeBorder";
|
import ResizeBorder from "./components/ResizeBorder";
|
||||||
import ResizeHandle from "./components/ResizeHandle";
|
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||||
import usePrevious from "hooks/usePrevious";
|
import usePrevious from "hooks/usePrevious";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
|
|
||||||
let firstRender = true;
|
let firstRender = true;
|
||||||
let BOUNCE_ANIMATION_MS = 250;
|
let ANIMATION_MS = 250;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
location: Location,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
function Sidebar({ children }: Props) {
|
||||||
|
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { ui } = useStores();
|
||||||
|
const location = useLocation();
|
||||||
|
const previousLocation = usePrevious(location);
|
||||||
|
|
||||||
|
const width = ui.sidebarWidth;
|
||||||
|
const collapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||||
|
const maxWidth = theme.sidebarMaxWidth;
|
||||||
|
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||||
|
const setWidth = ui.setSidebarWidth;
|
||||||
|
|
||||||
const [offset, setOffset] = React.useState(0);
|
const [offset, setOffset] = React.useState(0);
|
||||||
const [isAnimating, setAnimating] = React.useState(false);
|
const [isAnimating, setAnimating] = React.useState(false);
|
||||||
const [isResizing, setResizing] = React.useState(false);
|
const [isResizing, setResizing] = React.useState(false);
|
||||||
@ -38,24 +46,45 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
|||||||
|
|
||||||
// this is simple because the sidebar is always against the left edge
|
// this is simple because the sidebar is always against the left edge
|
||||||
const width = Math.min(event.pageX - offset, maxWidth);
|
const width = Math.min(event.pageX - offset, maxWidth);
|
||||||
setWidth(width);
|
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||||
},
|
|
||||||
[offset, maxWidth, setWidth]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStopDrag = React.useCallback(() => {
|
if (isSmallerThanCollapsePoint) {
|
||||||
setResizing(false);
|
setWidth(theme.sidebarCollapsedWidth);
|
||||||
|
|
||||||
if (isSmallerThanMinimum) {
|
|
||||||
setWidth(minWidth);
|
|
||||||
setAnimating(true);
|
|
||||||
} else {
|
} else {
|
||||||
setWidth(width);
|
setWidth(width);
|
||||||
}
|
}
|
||||||
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
},
|
||||||
|
[theme, offset, minWidth, maxWidth, setWidth]
|
||||||
|
);
|
||||||
|
|
||||||
const handleStartDrag = React.useCallback(
|
const handleStopDrag = React.useCallback(
|
||||||
(event) => {
|
(event: MouseEvent) => {
|
||||||
|
setResizing(false);
|
||||||
|
|
||||||
|
if (document.activeElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSmallerThanMinimum) {
|
||||||
|
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||||
|
|
||||||
|
if (isSmallerThanCollapsePoint) {
|
||||||
|
setAnimating(false);
|
||||||
|
setCollapsing(true);
|
||||||
|
ui.collapseSidebar();
|
||||||
|
} else {
|
||||||
|
setWidth(minWidth);
|
||||||
|
setAnimating(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setWidth(width);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = React.useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
setOffset(event.pageX - width);
|
setOffset(event.pageX - width);
|
||||||
setResizing(true);
|
setResizing(true);
|
||||||
setAnimating(false);
|
setAnimating(false);
|
||||||
@ -65,10 +94,19 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isAnimating) {
|
if (isAnimating) {
|
||||||
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||||
}
|
}
|
||||||
}, [isAnimating]);
|
}, [isAnimating]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isCollapsing) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setWidth(minWidth);
|
||||||
|
setCollapsing(false);
|
||||||
|
}, ANIMATION_MS);
|
||||||
|
}
|
||||||
|
}, [setWidth, minWidth, isCollapsing]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
document.addEventListener("mousemove", handleDrag);
|
document.addEventListener("mousemove", handleDrag);
|
||||||
@ -81,32 +119,6 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
|||||||
};
|
};
|
||||||
}, [isResizing, handleDrag, handleStopDrag]);
|
}, [isResizing, handleDrag, handleStopDrag]);
|
||||||
|
|
||||||
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
|
||||||
};
|
|
||||||
|
|
||||||
function Sidebar({ location, children }: Props) {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { ui } = useStores();
|
|
||||||
const previousLocation = usePrevious(location);
|
|
||||||
|
|
||||||
const width = ui.sidebarWidth;
|
|
||||||
const maxWidth = theme.sidebarMaxWidth;
|
|
||||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
|
||||||
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
|
||||||
|
|
||||||
const {
|
|
||||||
isAnimating,
|
|
||||||
isSmallerThanMinimum,
|
|
||||||
isResizing,
|
|
||||||
handleStartDrag,
|
|
||||||
} = useResize({
|
|
||||||
width,
|
|
||||||
minWidth,
|
|
||||||
maxWidth,
|
|
||||||
setWidth: ui.setSidebarWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleReset = React.useCallback(() => {
|
const handleReset = React.useCallback(() => {
|
||||||
ui.setSidebarWidth(theme.sidebarWidth);
|
ui.setSidebarWidth(theme.sidebarWidth);
|
||||||
}, [ui, theme.sidebarWidth]);
|
}, [ui, theme.sidebarWidth]);
|
||||||
@ -124,30 +136,30 @@ function Sidebar({ location, children }: Props) {
|
|||||||
const style = React.useMemo(
|
const style = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
left:
|
|
||||||
collapsed && !ui.mobileSidebarVisible
|
|
||||||
? `${-width + theme.sidebarCollapsedWidth}px`
|
|
||||||
: 0,
|
|
||||||
}),
|
}),
|
||||||
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
[width]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleStyle = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
right: "auto",
|
||||||
|
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||||
|
}),
|
||||||
|
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
|
<>
|
||||||
<Container
|
<Container
|
||||||
style={style}
|
style={style}
|
||||||
$sidebarWidth={ui.sidebarWidth}
|
$sidebarWidth={ui.sidebarWidth}
|
||||||
|
$isCollapsing={isCollapsing}
|
||||||
$isAnimating={isAnimating}
|
$isAnimating={isAnimating}
|
||||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||||
$collapsed={collapsed}
|
$collapsed={collapsed}
|
||||||
column
|
column
|
||||||
>
|
>
|
||||||
{!isResizing && (
|
|
||||||
<CollapseToggle
|
|
||||||
collapsed={ui.sidebarCollapsed}
|
|
||||||
onClick={ui.toggleCollapsedSidebar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{ui.mobileSidebarVisible && (
|
{ui.mobileSidebarVisible && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<Fade>
|
<Fade>
|
||||||
@ -155,18 +167,29 @@ function Sidebar({ location, children }: Props) {
|
|||||||
</Fade>
|
</Fade>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
{!ui.sidebarCollapsed && (
|
|
||||||
<ResizeBorder
|
<ResizeBorder
|
||||||
onMouseDown={handleStartDrag}
|
onMouseDown={handleMouseDown}
|
||||||
onDoubleClick={handleReset}
|
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
|
||||||
$isResizing={isResizing}
|
$isResizing={isResizing}
|
||||||
>
|
/>
|
||||||
<ResizeHandle aria-label={t("Resize sidebar")} />
|
{ui.sidebarCollapsed && !ui.isEditing && (
|
||||||
</ResizeBorder>
|
<Toggle
|
||||||
|
onClick={ui.toggleCollapsedSidebar}
|
||||||
|
direction={"right"}
|
||||||
|
aria-label={t("Expand")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
{!ui.isEditing && (
|
||||||
|
<Toggle
|
||||||
|
style={toggleStyle}
|
||||||
|
onClick={ui.toggleCollapsedSidebar}
|
||||||
|
direction={ui.sidebarCollapsed ? "right" : "left"}
|
||||||
|
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fade in the sidebar on first render after page load
|
// Fade in the sidebar on first render after page load
|
||||||
@ -195,29 +218,36 @@ const Container = styled(Flex)`
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: ${(props) => props.theme.sidebarBackground};
|
background: ${(props) => props.theme.sidebarBackground};
|
||||||
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
|
||||||
left 100ms ease-out,
|
|
||||||
${(props) => props.theme.backgroundTransition}
|
${(props) => props.theme.backgroundTransition}
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||||
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
transform: translateX(
|
||||||
|
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||||
|
);
|
||||||
z-index: ${(props) => props.theme.depths.sidebar};
|
z-index: ${(props) => props.theme.depths.sidebar};
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
|
|
||||||
|
${Positioner} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
display: none;
|
display: none;
|
||||||
left: 0;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
transform: translateX(${(props) =>
|
||||||
|
props.$collapsed ? "calc(-100% + 16px)" : 0});
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
left: 0 !important;
|
transform: none;
|
||||||
box-shadow: ${(props) =>
|
box-shadow: ${(props) =>
|
||||||
props.$collapsed
|
props.$collapsed
|
||||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||||
@ -225,11 +255,11 @@ const Container = styled(Flex)`
|
|||||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||||
: "none"};
|
: "none"};
|
||||||
|
|
||||||
& ${CollapseButton} {
|
${Positioner} {
|
||||||
opacity: .75;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
& ${CollapseButton}:hover {
|
${ToggleButton} {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -241,4 +271,4 @@ const Container = styled(Flex)`
|
|||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withRouter(observer(Sidebar));
|
export default observer(Sidebar);
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
// @flow
|
|
||||||
import { NextIcon, BackIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import Tooltip from "components/Tooltip";
|
|
||||||
import { meta } from "utils/keyboard";
|
|
||||||
|
|
||||||
type Props = {|
|
|
||||||
collapsed: boolean,
|
|
||||||
onClick?: (event: SyntheticEvent<>) => void,
|
|
||||||
|};
|
|
||||||
|
|
||||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
tooltip={collapsed ? t("Expand") : t("Collapse")}
|
|
||||||
shortcut={`${meta}+.`}
|
|
||||||
delay={500}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<Button {...rest} tabIndex="-1" aria-hidden>
|
|
||||||
{collapsed ? (
|
|
||||||
<NextIcon color="currentColor" />
|
|
||||||
) : (
|
|
||||||
<BackIcon color="currentColor" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Button = styled.button`
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 28px;
|
|
||||||
right: 8px;
|
|
||||||
border: 0;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
z-index: 1;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${(props) => props.theme.sidebarText};
|
|
||||||
background: transparent;
|
|
||||||
transition: opacity 100ms ease-in-out;
|
|
||||||
border-radius: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: ${(props) => props.theme.white};
|
|
||||||
background: ${(props) => props.theme.primary};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default CollapseToggle;
|
|
@ -42,8 +42,6 @@ class Collections extends React.Component<Props> {
|
|||||||
|
|
||||||
@keydown("n")
|
@keydown("n")
|
||||||
goToNewDocument() {
|
goToNewDocument() {
|
||||||
if (this.props.ui.editMode) return;
|
|
||||||
|
|
||||||
const { activeCollectionId } = this.props.ui;
|
const { activeCollectionId } = this.props.ui;
|
||||||
if (!activeCollectionId) return;
|
if (!activeCollectionId) return;
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import ResizeHandle from "./ResizeHandle";
|
|
||||||
|
|
||||||
const ResizeBorder = styled.div`
|
const ResizeBorder = styled.div`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -9,20 +8,6 @@ const ResizeBorder = styled.div`
|
|||||||
right: -6px;
|
right: -6px;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
|
|
||||||
${(props) =>
|
|
||||||
props.$isResizing &&
|
|
||||||
`
|
|
||||||
${ResizeHandle} {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
${ResizeHandle} {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default ResizeBorder;
|
export default ResizeBorder;
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
// @flow
|
|
||||||
import styled from "styled-components";
|
|
||||||
import breakpoint from "styled-components-breakpoint";
|
|
||||||
|
|
||||||
const ResizeHandle = styled.button`
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 100ms ease-in-out;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
height: 40px;
|
|
||||||
right: -10px;
|
|
||||||
width: 8px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: ${(props) => props.theme.sidebarBackground};
|
|
||||||
border-radius: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: -24px;
|
|
||||||
bottom: -24px;
|
|
||||||
left: -12px;
|
|
||||||
right: -12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: ${(props) => props.theme.sidebarText};
|
|
||||||
}
|
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
|
||||||
pointer-events: all;
|
|
||||||
cursor: ew-resize;
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default ResizeHandle;
|
|
75
app/components/Sidebar/components/Toggle.js
Normal file
75
app/components/Sidebar/components/Toggle.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
direction: "left" | "right",
|
||||||
|
style?: Object,
|
||||||
|
onClick?: () => any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
|
||||||
|
({ direction = "left", onClick, style }: Props, ref) => {
|
||||||
|
return (
|
||||||
|
<Positioner style={style}>
|
||||||
|
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 13 30"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</ToggleButton>
|
||||||
|
</Positioner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ToggleButton = styled.button`
|
||||||
|
opacity: 0;
|
||||||
|
background: none;
|
||||||
|
transition: opacity 100ms ease-in-out;
|
||||||
|
transform: translateY(-50%)
|
||||||
|
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
|
||||||
|
position: absolute;
|
||||||
|
top: 50vh;
|
||||||
|
padding: 8px;
|
||||||
|
border: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
color: ${(props) => props.theme.divider};
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: ${(props) => props.theme.sidebarText};
|
||||||
|
}
|
||||||
|
|
||||||
|
${breakpoint("tablet")`
|
||||||
|
pointer-events: all;
|
||||||
|
cursor: pointer;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Positioner = styled.div`
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: -30px;
|
||||||
|
width: 30px;
|
||||||
|
|
||||||
|
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Toggle;
|
@ -13,17 +13,23 @@ type Props = {|
|
|||||||
id?: string,
|
id?: string,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
|
||||||
const component = (
|
const component = (
|
||||||
<Wrapper width={width} height={height}>
|
<Wrapper width={width} height={height}>
|
||||||
<HiddenInput type="checkbox" width={width} height={height} {...props} />
|
<HiddenInput
|
||||||
|
type="checkbox"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
<Slider width={width} height={height} />
|
<Slider width={width} height={height} />
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (label) {
|
if (label) {
|
||||||
return (
|
return (
|
||||||
<Label htmlFor={props.id}>
|
<Label disabled={disabled} htmlFor={props.id}>
|
||||||
{component}
|
{component}
|
||||||
<LabelText>{label}</LabelText>
|
<LabelText>{label}</LabelText>
|
||||||
</Label>
|
</Label>
|
||||||
@ -36,6 +42,8 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
|||||||
const Label = styled.label`
|
const Label = styled.label`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Wrapper = styled.label`
|
const Wrapper = styled.label`
|
||||||
@ -79,6 +87,11 @@ const HiddenInput = styled.input`
|
|||||||
height: 0;
|
height: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
|
||||||
|
&:disabled + ${Slider} {
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
&:checked + ${Slider} {
|
&:checked + ${Slider} {
|
||||||
background-color: ${(props) => props.theme.primary};
|
background-color: ${(props) => props.theme.primary};
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,8 @@ type Props = {
|
|||||||
|
|
||||||
const StyledNavLink = styled(NavLink)`
|
const StyledNavLink = styled(NavLink)`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
display: inline-block;
|
align-items: center;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: ${(props) => props.theme.textTertiary};
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
@ -8,6 +8,7 @@ const Tabs = styled.nav`
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: opacity 250ms ease-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Separator = styled.span`
|
export const Separator = styled.span`
|
||||||
|
@ -64,6 +64,10 @@ function CollectionMenu({
|
|||||||
[history, collection.id]
|
[history, collection.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleImportDocument = React.useCallback(
|
const handleImportDocument = React.useCallback(
|
||||||
(ev: SyntheticEvent<>) => {
|
(ev: SyntheticEvent<>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@ -83,20 +87,19 @@ function CollectionMenu({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const document = await documents.import(
|
const document = await documents.import(file, null, collection.id, {
|
||||||
file,
|
publish: true,
|
||||||
null,
|
});
|
||||||
this.props.collection.id,
|
|
||||||
{ publish: true }
|
|
||||||
);
|
|
||||||
history.push(document.url);
|
history.push(document.url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.showToast(err.message, {
|
ui.showToast(err.message, {
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[history, ui, documents]
|
[history, ui, collection.id, documents]
|
||||||
);
|
);
|
||||||
|
|
||||||
const can = policies.abilities(collection.id);
|
const can = policies.abilities(collection.id);
|
||||||
@ -108,7 +111,7 @@ function CollectionMenu({
|
|||||||
type="file"
|
type="file"
|
||||||
ref={file}
|
ref={file}
|
||||||
onChange={handleFilePicked}
|
onChange={handleFilePicked}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
onClick={stopPropagation}
|
||||||
accept={documents.importFileTypes.join(", ")}
|
accept={documents.importFileTypes.join(", ")}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
/>
|
/>
|
||||||
@ -146,7 +149,7 @@ function CollectionMenu({
|
|||||||
onClick: () => setShowCollectionEdit(true),
|
onClick: () => setShowCollectionEdit(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `${t("Permissions")}…`,
|
title: `${t("Members")}…`,
|
||||||
visible: can.update,
|
visible: can.update,
|
||||||
onClick: () => setShowCollectionMembers(true),
|
onClick: () => setShowCollectionMembers(true),
|
||||||
},
|
},
|
||||||
@ -172,7 +175,7 @@ function CollectionMenu({
|
|||||||
{renderModals && (
|
{renderModals && (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title={t("Collection permissions")}
|
title={t("Collection members")}
|
||||||
onRequestClose={() => setShowCollectionMembers(false)}
|
onRequestClose={() => setShowCollectionMembers(false)}
|
||||||
isOpen={showCollectionMembers}
|
isOpen={showCollectionMembers}
|
||||||
>
|
>
|
||||||
|
@ -15,6 +15,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
|||||||
import Template from "components/ContextMenu/Template";
|
import Template from "components/ContextMenu/Template";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import Modal from "components/Modal";
|
import Modal from "components/Modal";
|
||||||
|
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
import {
|
import {
|
||||||
documentHistoryUrl,
|
documentHistoryUrl,
|
||||||
@ -49,7 +50,8 @@ function DocumentMenu({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { policies, collections, auth, ui } = useStores();
|
const team = useCurrentTeam();
|
||||||
|
const { policies, collections, ui } = useStores();
|
||||||
const menu = useMenuState({ modal });
|
const menu = useMenuState({ modal });
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -130,10 +132,10 @@ function DocumentMenu({
|
|||||||
[document]
|
[document]
|
||||||
);
|
);
|
||||||
|
|
||||||
const can = policies.abilities(document.id);
|
|
||||||
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
|
|
||||||
const canViewHistory = can.read && !can.restore;
|
|
||||||
const collection = collections.get(document.collectionId);
|
const collection = collections.get(document.collectionId);
|
||||||
|
const can = policies.abilities(document.id);
|
||||||
|
const canShareDocuments = !!(can.share && team.sharing);
|
||||||
|
const canViewHistory = can.read && !can.restore;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -16,6 +16,7 @@ export default class Collection extends BaseModel {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
private: boolean;
|
private: boolean;
|
||||||
|
sharing: boolean;
|
||||||
documents: NavigationNode[];
|
documents: NavigationNode[];
|
||||||
createdAt: ?string;
|
createdAt: ?string;
|
||||||
updatedAt: ?string;
|
updatedAt: ?string;
|
||||||
@ -112,6 +113,7 @@ export default class Collection extends BaseModel {
|
|||||||
"name",
|
"name",
|
||||||
"color",
|
"color",
|
||||||
"description",
|
"description",
|
||||||
|
"sharing",
|
||||||
"icon",
|
"icon",
|
||||||
"private",
|
"private",
|
||||||
"sort",
|
"sort",
|
||||||
|
@ -230,7 +230,7 @@ class CollectionScene extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
<Modal
|
<Modal
|
||||||
title={t("Collection permissions")}
|
title={t("Collection members")}
|
||||||
onRequestClose={this.handlePermissionsModalClose}
|
onRequestClose={this.handlePermissionsModalClose}
|
||||||
isOpen={this.permissionsModalOpen}
|
isOpen={this.permissionsModalOpen}
|
||||||
>
|
>
|
||||||
|
@ -3,6 +3,7 @@ import { observable } from "mobx";
|
|||||||
import { inject, observer } from "mobx-react";
|
import { inject, observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||||
|
import AuthStore from "stores/AuthStore";
|
||||||
import UiStore from "stores/UiStore";
|
import UiStore from "stores/UiStore";
|
||||||
import Collection from "models/Collection";
|
import Collection from "models/Collection";
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
@ -17,6 +18,7 @@ import Switch from "components/Switch";
|
|||||||
type Props = {
|
type Props = {
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
|
auth: AuthStore,
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
};
|
};
|
||||||
@ -24,6 +26,7 @@ type Props = {
|
|||||||
@observer
|
@observer
|
||||||
class CollectionEdit extends React.Component<Props> {
|
class CollectionEdit extends React.Component<Props> {
|
||||||
@observable name: string = this.props.collection.name;
|
@observable name: string = this.props.collection.name;
|
||||||
|
@observable sharing: boolean = this.props.collection.sharing;
|
||||||
@observable description: string = this.props.collection.description;
|
@observable description: string = this.props.collection.description;
|
||||||
@observable icon: string = this.props.collection.icon;
|
@observable icon: string = this.props.collection.icon;
|
||||||
@observable color: string = this.props.collection.color || "#4E5C6E";
|
@observable color: string = this.props.collection.color || "#4E5C6E";
|
||||||
@ -44,6 +47,7 @@ class CollectionEdit extends React.Component<Props> {
|
|||||||
icon: this.icon,
|
icon: this.icon,
|
||||||
color: this.color,
|
color: this.color,
|
||||||
private: this.private,
|
private: this.private,
|
||||||
|
sharing: this.sharing,
|
||||||
sort: this.sort,
|
sort: this.sort,
|
||||||
});
|
});
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
@ -82,8 +86,13 @@ class CollectionEdit extends React.Component<Props> {
|
|||||||
this.private = ev.target.checked;
|
this.private = ev.target.checked;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
|
||||||
|
this.sharing = ev.target.checked;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { t } = this.props;
|
const { auth, t } = this.props;
|
||||||
|
const teamSharingEnabled = !!auth.team && auth.team.sharing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex column>
|
<Flex column>
|
||||||
@ -140,6 +149,25 @@ class CollectionEdit extends React.Component<Props> {
|
|||||||
A private collection will only be visible to invited team members.
|
A private collection will only be visible to invited team members.
|
||||||
</Trans>
|
</Trans>
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
<Switch
|
||||||
|
id="sharing"
|
||||||
|
label={t("Public document sharing")}
|
||||||
|
onChange={this.handleSharingChange}
|
||||||
|
checked={this.sharing && teamSharingEnabled}
|
||||||
|
disabled={!teamSharingEnabled}
|
||||||
|
/>
|
||||||
|
<HelpText>
|
||||||
|
{teamSharingEnabled ? (
|
||||||
|
<Trans>
|
||||||
|
When enabled, documents can be shared publicly on the internet.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Public sharing is currently disabled in the team security
|
||||||
|
settings.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</HelpText>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={this.isSaving || !this.props.collection.name}
|
disabled={this.isSaving || !this.props.collection.name}
|
||||||
@ -152,4 +180,6 @@ class CollectionEdit extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTranslation()<CollectionEdit>(inject("ui")(CollectionEdit));
|
export default withTranslation()<CollectionEdit>(
|
||||||
|
inject("ui", "auth")(CollectionEdit)
|
||||||
|
);
|
||||||
|
@ -3,8 +3,9 @@ import { intersection } from "lodash";
|
|||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { inject, observer } from "mobx-react";
|
import { inject, observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { withTranslation, type TFunction } from "react-i18next";
|
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||||
|
import AuthStore from "stores/AuthStore";
|
||||||
import CollectionsStore from "stores/CollectionsStore";
|
import CollectionsStore from "stores/CollectionsStore";
|
||||||
import UiStore from "stores/UiStore";
|
import UiStore from "stores/UiStore";
|
||||||
import Collection from "models/Collection";
|
import Collection from "models/Collection";
|
||||||
@ -18,6 +19,7 @@ import Switch from "components/Switch";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
history: RouterHistory,
|
history: RouterHistory,
|
||||||
|
auth: AuthStore,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
@ -30,6 +32,7 @@ class CollectionNew extends React.Component<Props> {
|
|||||||
@observable description: string = "";
|
@observable description: string = "";
|
||||||
@observable icon: string = "";
|
@observable icon: string = "";
|
||||||
@observable color: string = "#4E5C6E";
|
@observable color: string = "#4E5C6E";
|
||||||
|
@observable sharing: boolean = true;
|
||||||
@observable private: boolean = false;
|
@observable private: boolean = false;
|
||||||
@observable isSaving: boolean;
|
@observable isSaving: boolean;
|
||||||
hasOpenedIconPicker: boolean = false;
|
hasOpenedIconPicker: boolean = false;
|
||||||
@ -41,6 +44,7 @@ class CollectionNew extends React.Component<Props> {
|
|||||||
{
|
{
|
||||||
name: this.name,
|
name: this.name,
|
||||||
description: this.description,
|
description: this.description,
|
||||||
|
sharing: this.sharing,
|
||||||
icon: this.icon,
|
icon: this.icon,
|
||||||
color: this.color,
|
color: this.color,
|
||||||
private: this.private,
|
private: this.private,
|
||||||
@ -59,7 +63,7 @@ class CollectionNew extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
handleNameChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||||
this.name = ev.target.value;
|
this.name = ev.target.value;
|
||||||
|
|
||||||
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
||||||
@ -90,24 +94,31 @@ class CollectionNew extends React.Component<Props> {
|
|||||||
this.description = getValue();
|
this.description = getValue();
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
|
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||||
this.private = ev.target.checked;
|
this.private = ev.target.checked;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||||
|
this.sharing = ev.target.checked;
|
||||||
|
};
|
||||||
|
|
||||||
handleChange = (color: string, icon: string) => {
|
handleChange = (color: string, icon: string) => {
|
||||||
this.color = color;
|
this.color = color;
|
||||||
this.icon = icon;
|
this.icon = icon;
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { t } = this.props;
|
const { t, auth } = this.props;
|
||||||
|
const teamSharingEnabled = !!auth.team && auth.team.sharing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
{t(
|
<Trans>
|
||||||
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example."
|
Collections are for grouping your knowledge base. They work best
|
||||||
)}
|
when organized around a topic or internal team — Product or
|
||||||
|
Engineering for example.
|
||||||
|
</Trans>
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<Flex>
|
<Flex>
|
||||||
<Input
|
<Input
|
||||||
@ -142,10 +153,25 @@ class CollectionNew extends React.Component<Props> {
|
|||||||
checked={this.private}
|
checked={this.private}
|
||||||
/>
|
/>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
{t(
|
<Trans>
|
||||||
"A private collection will only be visible to invited team members."
|
A private collection will only be visible to invited team members.
|
||||||
)}
|
</Trans>
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
{teamSharingEnabled && (
|
||||||
|
<>
|
||||||
|
<Switch
|
||||||
|
id="sharing"
|
||||||
|
label={t("Public document sharing")}
|
||||||
|
onChange={this.handleSharingChange}
|
||||||
|
checked={this.sharing}
|
||||||
|
/>
|
||||||
|
<HelpText>
|
||||||
|
<Trans>
|
||||||
|
When enabled, documents can be shared publicly on the internet.
|
||||||
|
</Trans>
|
||||||
|
</HelpText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||||
{this.isSaving ? `${t("Creating")}…` : t("Create")}
|
{this.isSaving ? `${t("Creating")}…` : t("Create")}
|
||||||
@ -156,5 +182,5 @@ class CollectionNew extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default withTranslation()<CollectionNew>(
|
export default withTranslation()<CollectionNew>(
|
||||||
inject("collections", "ui")(withRouter(CollectionNew))
|
inject("collections", "ui", "auth")(withRouter(CollectionNew))
|
||||||
);
|
);
|
||||||
|
@ -126,7 +126,7 @@ class Header extends React.Component<Props> {
|
|||||||
const isNew = document.isNew;
|
const isNew = document.isNew;
|
||||||
const isTemplate = document.isTemplate;
|
const isTemplate = document.isTemplate;
|
||||||
const can = policies.abilities(document.id);
|
const can = policies.abilities(document.id);
|
||||||
const canShareDocuments = auth.team && auth.team.sharing && can.share;
|
const canShareDocument = auth.team && auth.team.sharing && can.share;
|
||||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||||
const canEdit = can.update && !isEditing;
|
const canEdit = can.update && !isEditing;
|
||||||
|
|
||||||
@ -200,7 +200,7 @@ class Header extends React.Component<Props> {
|
|||||||
<TemplatesMenu document={document} />
|
<TemplatesMenu document={document} />
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
{!isEditing && canShareDocuments && (
|
{!isEditing && canShareDocument && (
|
||||||
<Action>
|
<Action>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltip={
|
tooltip={
|
||||||
|
@ -4,12 +4,13 @@ import { observable } from "mobx";
|
|||||||
import { observer, inject } from "mobx-react";
|
import { observer, inject } from "mobx-react";
|
||||||
import { PlusIcon } from "outline-icons";
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||||
import { type Match } from "react-router-dom";
|
import { type Match } from "react-router-dom";
|
||||||
|
|
||||||
import AuthStore from "stores/AuthStore";
|
import AuthStore from "stores/AuthStore";
|
||||||
import PoliciesStore from "stores/PoliciesStore";
|
import PoliciesStore from "stores/PoliciesStore";
|
||||||
import UsersStore from "stores/UsersStore";
|
import UsersStore from "stores/UsersStore";
|
||||||
import Invite from "scenes/Invite";
|
import Invite from "scenes/Invite";
|
||||||
|
import Bubble from "components/Bubble";
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
import CenteredContent from "components/CenteredContent";
|
import CenteredContent from "components/CenteredContent";
|
||||||
import Empty from "components/Empty";
|
import Empty from "components/Empty";
|
||||||
@ -27,12 +28,20 @@ type Props = {
|
|||||||
users: UsersStore,
|
users: UsersStore,
|
||||||
policies: PoliciesStore,
|
policies: PoliciesStore,
|
||||||
match: Match,
|
match: Match,
|
||||||
|
t: TFunction,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class People extends React.Component<Props> {
|
class People extends React.Component<Props> {
|
||||||
@observable inviteModalOpen: boolean = false;
|
@observable inviteModalOpen: boolean = false;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { team } = this.props.auth;
|
||||||
|
if (team) {
|
||||||
|
this.props.users.fetchCounts(team.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleInviteModalOpen = () => {
|
handleInviteModalOpen = () => {
|
||||||
this.inviteModalOpen = true;
|
this.inviteModalOpen = true;
|
||||||
};
|
};
|
||||||
@ -46,7 +55,7 @@ class People extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { auth, policies, match } = this.props;
|
const { auth, policies, match, t } = this.props;
|
||||||
const { filter } = match.params;
|
const { filter } = match.params;
|
||||||
const currentUser = auth.user;
|
const currentUser = auth.user;
|
||||||
const team = auth.team;
|
const team = auth.team;
|
||||||
@ -65,15 +74,18 @@ class People extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const can = policies.abilities(team.id);
|
const can = policies.abilities(team.id);
|
||||||
|
const { counts } = this.props.users;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<PageTitle title="People" />
|
<PageTitle title={t("People")} />
|
||||||
<h1>People</h1>
|
<h1>{t("People")}</h1>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Everyone that has signed into Outline appears here. It’s possible that
|
<Trans>
|
||||||
there are other users who have access through {team.signinMethods} but
|
Everyone that has signed into Outline appears here. It’s possible
|
||||||
haven’t signed in yet.
|
that there are other users who have access through{" "}
|
||||||
|
{team.signinMethods} but haven’t signed in yet.
|
||||||
|
</Trans>
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -84,37 +96,36 @@ class People extends React.Component<Props> {
|
|||||||
icon={<PlusIcon />}
|
icon={<PlusIcon />}
|
||||||
neutral
|
neutral
|
||||||
>
|
>
|
||||||
Invite people…
|
{t("Invite people")}…
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab to="/settings/people" exact>
|
<Tab to="/settings/people" exact>
|
||||||
Active
|
{t("Active")} <Bubble count={counts.active} />
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to="/settings/people/admins" exact>
|
<Tab to="/settings/people/admins" exact>
|
||||||
Admins
|
{t("Admins")} <Bubble count={counts.admins} />
|
||||||
</Tab>
|
</Tab>
|
||||||
{can.update && (
|
{can.update && (
|
||||||
<Tab to="/settings/people/suspended" exact>
|
<Tab to="/settings/people/suspended" exact>
|
||||||
Suspended
|
{t("Suspended")} <Bubble count={counts.suspended} />
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
)}
|
||||||
<Tab to="/settings/people/all" exact>
|
<Tab to="/settings/people/all" exact>
|
||||||
Everyone
|
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
{can.invite && (
|
{can.invite && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Tab to="/settings/people/invited" exact>
|
<Tab to="/settings/people/invited" exact>
|
||||||
Invited
|
{t("Invited")} <Bubble count={counts.invited} />
|
||||||
</Tab>
|
</Tab>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
items={users}
|
items={users}
|
||||||
empty={<Empty>No people to see here.</Empty>}
|
empty={<Empty>{t("No people to see here.")}</Empty>}
|
||||||
fetch={this.fetchPage}
|
fetch={this.fetchPage}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<UserListItem
|
<UserListItem
|
||||||
@ -126,7 +137,7 @@ class People extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Invite people"
|
title={t("Invite people")}
|
||||||
onRequestClose={this.handleInviteModalClose}
|
onRequestClose={this.handleInviteModalClose}
|
||||||
isOpen={this.inviteModalOpen}
|
isOpen={this.inviteModalOpen}
|
||||||
>
|
>
|
||||||
@ -137,4 +148,8 @@ class People extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject("auth", "users", "policies")(People);
|
export default inject(
|
||||||
|
"auth",
|
||||||
|
"users",
|
||||||
|
"policies"
|
||||||
|
)(withTranslation()<People>(People));
|
||||||
|
@ -7,7 +7,7 @@ import BaseModel from "../models/BaseModel";
|
|||||||
import type { PaginationParams } from "types";
|
import type { PaginationParams } from "types";
|
||||||
import { client } from "utils/ApiClient";
|
import { client } from "utils/ApiClient";
|
||||||
|
|
||||||
type Action = "list" | "info" | "create" | "update" | "delete";
|
type Action = "list" | "info" | "create" | "update" | "delete" | "count";
|
||||||
|
|
||||||
function modelNameFromClassName(string) {
|
function modelNameFromClassName(string) {
|
||||||
return string.charAt(0).toLowerCase() + string.slice(1);
|
return string.charAt(0).toLowerCase() + string.slice(1);
|
||||||
@ -24,7 +24,7 @@ export default class BaseStore<T: BaseModel> {
|
|||||||
model: Class<T>;
|
model: Class<T>;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
rootStore: RootStore;
|
rootStore: RootStore;
|
||||||
actions: Action[] = ["list", "info", "create", "update", "delete"];
|
actions: Action[] = ["list", "info", "create", "update", "delete", "count"];
|
||||||
|
|
||||||
constructor(rootStore: RootStore, model: Class<T>) {
|
constructor(rootStore: RootStore, model: Class<T>) {
|
||||||
this.rootStore = rootStore;
|
this.rootStore = rootStore;
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { concat, filter, last } from "lodash";
|
import { concat, filter, last } from "lodash";
|
||||||
import { action, computed } from "mobx";
|
import { computed, action } from "mobx";
|
||||||
|
|
||||||
import naturalSort from "shared/utils/naturalSort";
|
import naturalSort from "shared/utils/naturalSort";
|
||||||
import Collection from "models/Collection";
|
import Collection from "models/Collection";
|
||||||
import BaseStore from "./BaseStore";
|
import BaseStore from "./BaseStore";
|
||||||
@ -104,6 +103,24 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async update(params: Object): Promise<Collection> {
|
||||||
|
const result = await super.update(params);
|
||||||
|
|
||||||
|
// If we're changing sharing permissions on the collection then we need to
|
||||||
|
// remove all locally cached policies for documents in the collection as they
|
||||||
|
// are now invalid
|
||||||
|
if (params.sharing !== undefined) {
|
||||||
|
const collection = this.get(params.id);
|
||||||
|
if (collection) {
|
||||||
|
collection.documentIds.forEach((id) => {
|
||||||
|
this.rootStore.policies.remove(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
getPathForDocument(documentId: string): ?DocumentPath {
|
getPathForDocument(documentId: string): ?DocumentPath {
|
||||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
@observable movingDocumentId: ?string;
|
@observable movingDocumentId: ?string;
|
||||||
|
|
||||||
importFileTypes: string[] = [
|
importFileTypes: string[] = [
|
||||||
|
".md",
|
||||||
"text/markdown",
|
"text/markdown",
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"text/html",
|
"text/html",
|
||||||
|
@ -21,7 +21,7 @@ class UiStore {
|
|||||||
@observable activeDocumentId: ?string;
|
@observable activeDocumentId: ?string;
|
||||||
@observable activeCollectionId: ?string;
|
@observable activeCollectionId: ?string;
|
||||||
@observable progressBarVisible: boolean = false;
|
@observable progressBarVisible: boolean = false;
|
||||||
@observable editMode: boolean = false;
|
@observable isEditing: boolean = false;
|
||||||
@observable tocVisible: boolean = false;
|
@observable tocVisible: boolean = false;
|
||||||
@observable mobileSidebarVisible: boolean = false;
|
@observable mobileSidebarVisible: boolean = false;
|
||||||
@observable sidebarWidth: number;
|
@observable sidebarWidth: number;
|
||||||
@ -151,12 +151,12 @@ class UiStore {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
enableEditMode = () => {
|
enableEditMode = () => {
|
||||||
this.editMode = true;
|
this.isEditing = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
disableEditMode = () => {
|
disableEditMode = () => {
|
||||||
this.editMode = false;
|
this.isEditing = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -1,13 +1,21 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { filter, orderBy } from "lodash";
|
import { filter, orderBy } from "lodash";
|
||||||
import { computed, action, runInAction } from "mobx";
|
import { observable, computed, action, runInAction } from "mobx";
|
||||||
import User from "models/User";
|
import User from "models/User";
|
||||||
import BaseStore from "./BaseStore";
|
import BaseStore from "./BaseStore";
|
||||||
import RootStore from "./RootStore";
|
import RootStore from "./RootStore";
|
||||||
import { client } from "utils/ApiClient";
|
import { client } from "utils/ApiClient";
|
||||||
|
|
||||||
export default class UsersStore extends BaseStore<User> {
|
export default class UsersStore extends BaseStore<User> {
|
||||||
|
@observable counts: {
|
||||||
|
active: number,
|
||||||
|
admins: number,
|
||||||
|
all: number,
|
||||||
|
invited: number,
|
||||||
|
suspended: number,
|
||||||
|
} = {};
|
||||||
|
|
||||||
constructor(rootStore: RootStore) {
|
constructor(rootStore: RootStore) {
|
||||||
super(rootStore, User);
|
super(rootStore, User);
|
||||||
}
|
}
|
||||||
@ -52,21 +60,25 @@ export default class UsersStore extends BaseStore<User> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
promote = (user: User) => {
|
promote = (user: User) => {
|
||||||
|
this.counts.admins += 1;
|
||||||
return this.actionOnUser("promote", user);
|
return this.actionOnUser("promote", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
demote = (user: User) => {
|
demote = (user: User) => {
|
||||||
|
this.counts.admins -= 1;
|
||||||
return this.actionOnUser("demote", user);
|
return this.actionOnUser("demote", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
suspend = (user: User) => {
|
suspend = (user: User) => {
|
||||||
|
this.counts.suspended += 1;
|
||||||
return this.actionOnUser("suspend", user);
|
return this.actionOnUser("suspend", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
activate = (user: User) => {
|
activate = (user: User) => {
|
||||||
|
this.counts.suspended -= 1;
|
||||||
return this.actionOnUser("activate", user);
|
return this.actionOnUser("activate", user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -76,10 +88,39 @@ export default class UsersStore extends BaseStore<User> {
|
|||||||
invariant(res && res.data, "Data should be available");
|
invariant(res && res.data, "Data should be available");
|
||||||
runInAction(`invite`, () => {
|
runInAction(`invite`, () => {
|
||||||
res.data.users.forEach(this.add);
|
res.data.users.forEach(this.add);
|
||||||
|
this.counts.invited += res.data.sent.length;
|
||||||
|
this.counts.all += res.data.sent.length;
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchCounts = async (teamId: string): Promise<*> => {
|
||||||
|
const res = await client.post(`/users.count`, { teamId });
|
||||||
|
invariant(res && res.data, "Data should be available");
|
||||||
|
|
||||||
|
this.counts = res.data.counts;
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
async delete(user: User, options: Object = {}) {
|
||||||
|
super.delete(user, options);
|
||||||
|
if (!user.isSuspended && user.lastActiveAt) {
|
||||||
|
this.counts.active -= 1;
|
||||||
|
}
|
||||||
|
if (user.isInvited) {
|
||||||
|
this.counts.invited -= 1;
|
||||||
|
}
|
||||||
|
if (user.isAdmin) {
|
||||||
|
this.counts.admins -= 1;
|
||||||
|
}
|
||||||
|
if (user.isSuspended) {
|
||||||
|
this.counts.suspended -= 1;
|
||||||
|
}
|
||||||
|
this.counts.all -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
notInCollection = (collectionId: string, query: string = "") => {
|
notInCollection = (collectionId: string, query: string = "") => {
|
||||||
const memberships = filter(
|
const memberships = filter(
|
||||||
this.rootStore.memberships.orderedData,
|
this.rootStore.memberships.orderedData,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { map, trim } from "lodash";
|
import { map, trim } from "lodash";
|
||||||
|
import { getCookie } from "tiny-cookie";
|
||||||
import stores from "stores";
|
import stores from "stores";
|
||||||
import download from "./download";
|
import download from "./download";
|
||||||
import {
|
import {
|
||||||
@ -18,6 +19,11 @@ type Options = {
|
|||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// authorization cookie set by a Cloudflare Access proxy
|
||||||
|
const CF_AUTHORIZATION = getCookie("CF_Authorization");
|
||||||
|
// if the cookie is set, we must pass it with all ApiClient requests
|
||||||
|
const CREDENTIALS = CF_AUTHORIZATION ? "same-origin" : "omit";
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
@ -91,7 +97,7 @@ class ApiClient {
|
|||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
credentials: "omit",
|
credentials: CREDENTIALS,
|
||||||
cache: "no-cache",
|
cache: "no-cache",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -153,7 +153,7 @@
|
|||||||
"react-waypoint": "^9.0.2",
|
"react-waypoint": "^9.0.2",
|
||||||
"react-window": "^1.8.6",
|
"react-window": "^1.8.6",
|
||||||
"reakit": "^1.3.4",
|
"reakit": "^1.3.4",
|
||||||
"rich-markdown-editor": "^11.1.6",
|
"rich-markdown-editor": "^11.2.0-0",
|
||||||
"semver": "^7.3.2",
|
"semver": "^7.3.2",
|
||||||
"sequelize": "^6.3.4",
|
"sequelize": "^6.3.4",
|
||||||
"sequelize-cli": "^6.2.0",
|
"sequelize-cli": "^6.2.0",
|
||||||
|
@ -35,6 +35,7 @@ router.post("collections.create", auth(), async (ctx) => {
|
|||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
description,
|
description,
|
||||||
|
sharing,
|
||||||
icon,
|
icon,
|
||||||
sort = Collection.DEFAULT_SORT,
|
sort = Collection.DEFAULT_SORT,
|
||||||
} = ctx.body;
|
} = ctx.body;
|
||||||
@ -56,6 +57,7 @@ router.post("collections.create", auth(), async (ctx) => {
|
|||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
createdById: user.id,
|
createdById: user.id,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
|
sharing,
|
||||||
sort,
|
sort,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -491,7 +493,7 @@ router.post("collections.export_all", auth(), async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("collections.update", auth(), async (ctx) => {
|
router.post("collections.update", auth(), async (ctx) => {
|
||||||
let { id, name, description, icon, color, sort } = ctx.body;
|
let { id, name, description, icon, color, sort, sharing } = ctx.body;
|
||||||
const isPrivate = ctx.body.private;
|
const isPrivate = ctx.body.private;
|
||||||
|
|
||||||
if (color) {
|
if (color) {
|
||||||
@ -537,6 +539,9 @@ router.post("collections.update", auth(), async (ctx) => {
|
|||||||
if (isPrivate !== undefined) {
|
if (isPrivate !== undefined) {
|
||||||
collection.private = isPrivate;
|
collection.private = isPrivate;
|
||||||
}
|
}
|
||||||
|
if (sharing !== undefined) {
|
||||||
|
collection.sharing = sharing;
|
||||||
|
}
|
||||||
if (sort !== undefined) {
|
if (sort !== undefined) {
|
||||||
collection.sort = sort;
|
collection.sort = sort;
|
||||||
}
|
}
|
||||||
|
@ -897,6 +897,18 @@ describe("#collections.create", () => {
|
|||||||
expect(body.policies[0].abilities.export).toBeTruthy();
|
expect(body.policies[0].abilities.export).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow setting sharing to false", async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const res = await server.post("/api/collections.create", {
|
||||||
|
body: { token: user.getJwtToken(), name: "Test", sharing: false },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.id).toBeTruthy();
|
||||||
|
expect(body.data.sharing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return correct policies with private collection", async () => {
|
it("should return correct policies with private collection", async () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
const res = await server.post("/api/collections.create", {
|
const res = await server.post("/api/collections.create", {
|
||||||
|
@ -489,6 +489,11 @@ async function loadDocument({ id, shareId, user }) {
|
|||||||
authorize(user, "read", document);
|
authorize(user, "read", document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collection = await Collection.findByPk(document.collectionId);
|
||||||
|
if (!collection.sharing) {
|
||||||
|
throw new AuthorizationError();
|
||||||
|
}
|
||||||
|
|
||||||
const team = await Team.findByPk(document.teamId);
|
const team = await Team.findByPk(document.teamId);
|
||||||
if (!team.sharing) {
|
if (!team.sharing) {
|
||||||
throw new AuthorizationError();
|
throw new AuthorizationError();
|
||||||
|
@ -112,6 +112,23 @@ describe("#documents.info", () => {
|
|||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not return document from shareId if sharing is disabled for collection", async () => {
|
||||||
|
const { document, collection, user } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: document.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
collection.sharing = false;
|
||||||
|
await collection.save();
|
||||||
|
|
||||||
|
const res = await server.post("/api/documents.info", {
|
||||||
|
body: { shareId: share.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
it("should not return document from revoked shareId", async () => {
|
it("should not return document from revoked shareId", async () => {
|
||||||
const { document, user } = await seed();
|
const { document, user } = await seed();
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
|
@ -202,7 +202,7 @@ describe("#shares.create", () => {
|
|||||||
expect(body.data.id).toBe(share.id);
|
expect(body.data.id).toBe(share.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not allow creating a share record if disabled", async () => {
|
it("should not allow creating a share record if team sharing disabled", async () => {
|
||||||
const { user, document, team } = await seed();
|
const { user, document, team } = await seed();
|
||||||
await team.update({ sharing: false });
|
await team.update({ sharing: false });
|
||||||
const res = await server.post("/api/shares.create", {
|
const res = await server.post("/api/shares.create", {
|
||||||
@ -211,6 +211,15 @@ describe("#shares.create", () => {
|
|||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not allow creating a share record if collection sharing disabled", async () => {
|
||||||
|
const { user, collection, document } = await seed();
|
||||||
|
await collection.update({ sharing: false });
|
||||||
|
const res = await server.post("/api/shares.create", {
|
||||||
|
body: { token: user.getJwtToken(), documentId: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
it("should require authentication", async () => {
|
it("should require authentication", async () => {
|
||||||
const { document } = await seed();
|
const { document } = await seed();
|
||||||
const res = await server.post("/api/shares.create", {
|
const res = await server.post("/api/shares.create", {
|
||||||
|
@ -55,6 +55,17 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("users.count", auth(), async (ctx) => {
|
||||||
|
const { user } = ctx.state;
|
||||||
|
const counts = await User.getCounts(user.teamId);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: {
|
||||||
|
counts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post("users.info", auth(), async (ctx) => {
|
router.post("users.info", auth(), async (ctx) => {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(ctx.state.user),
|
data: presentUser(ctx.state.user),
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
import TestServer from "fetch-test-server";
|
import TestServer from "fetch-test-server";
|
||||||
import app from "../app";
|
import app from "../app";
|
||||||
|
|
||||||
import { buildUser } from "../test/factories";
|
import { buildTeam, buildUser } from "../test/factories";
|
||||||
|
|
||||||
import { flushdb, seed } from "../test/support";
|
import { flushdb, seed } from "../test/support";
|
||||||
|
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
@ -353,3 +354,75 @@ describe("#users.activate", () => {
|
|||||||
expect(body).toMatchSnapshot();
|
expect(body).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("#users.count", () => {
|
||||||
|
it("should count active users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(1);
|
||||||
|
expect(body.data.counts.admins).toEqual(0);
|
||||||
|
expect(body.data.counts.invited).toEqual(0);
|
||||||
|
expect(body.data.counts.suspended).toEqual(0);
|
||||||
|
expect(body.data.counts.active).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count admin users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id, isAdmin: true });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(1);
|
||||||
|
expect(body.data.counts.admins).toEqual(1);
|
||||||
|
expect(body.data.counts.invited).toEqual(0);
|
||||||
|
expect(body.data.counts.suspended).toEqual(0);
|
||||||
|
expect(body.data.counts.active).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count suspended users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id });
|
||||||
|
await buildUser({ teamId: team.id, suspendedAt: new Date() });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(2);
|
||||||
|
expect(body.data.counts.admins).toEqual(0);
|
||||||
|
expect(body.data.counts.invited).toEqual(0);
|
||||||
|
expect(body.data.counts.suspended).toEqual(1);
|
||||||
|
expect(body.data.counts.active).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count invited users", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
|
||||||
|
const res = await server.post("/api/users.count", {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.counts.all).toEqual(1);
|
||||||
|
expect(body.data.counts.admins).toEqual(0);
|
||||||
|
expect(body.data.counts.invited).toEqual(1);
|
||||||
|
expect(body.data.counts.suspended).toEqual(0);
|
||||||
|
expect(body.data.counts.active).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require authentication", async () => {
|
||||||
|
const res = await server.post("/api/users.count");
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('collections', 'sharing', {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('collections', 'sharing');
|
||||||
|
}
|
||||||
|
}
|
@ -24,6 +24,11 @@ const Collection = sequelize.define(
|
|||||||
private: DataTypes.BOOLEAN,
|
private: DataTypes.BOOLEAN,
|
||||||
maintainerApprovalRequired: DataTypes.BOOLEAN,
|
maintainerApprovalRequired: DataTypes.BOOLEAN,
|
||||||
documentStructure: DataTypes.JSONB,
|
documentStructure: DataTypes.JSONB,
|
||||||
|
sharing: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
sort: {
|
sort: {
|
||||||
type: DataTypes.JSONB,
|
type: DataTypes.JSONB,
|
||||||
validate: {
|
validate: {
|
||||||
|
@ -51,6 +51,9 @@ const User = sequelize.define(
|
|||||||
isSuspended() {
|
isSuspended() {
|
||||||
return !!this.suspendedAt;
|
return !!this.suspendedAt;
|
||||||
},
|
},
|
||||||
|
isInvited() {
|
||||||
|
return !this.lastActiveAt;
|
||||||
|
},
|
||||||
avatarUrl() {
|
avatarUrl() {
|
||||||
const original = this.getDataValue("avatarUrl");
|
const original = this.getDataValue("avatarUrl");
|
||||||
if (original) {
|
if (original) {
|
||||||
@ -267,4 +270,33 @@ User.afterCreate(async (user, options) => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
User.getCounts = async function (teamId: string) {
|
||||||
|
const countSql = `
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
||||||
|
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
|
||||||
|
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
|
||||||
|
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM users
|
||||||
|
WHERE "deletedAt" IS NULL
|
||||||
|
AND "teamId" = :teamId
|
||||||
|
`;
|
||||||
|
const results = await sequelize.query(countSql, {
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
replacements: {
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const counts = results[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: parseInt(counts.activeCount),
|
||||||
|
admins: parseInt(counts.adminCount),
|
||||||
|
all: parseInt(counts.count),
|
||||||
|
invited: parseInt(counts.invitedCount),
|
||||||
|
suspended: parseInt(counts.suspendedCount),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default User;
|
export default User;
|
||||||
|
@ -36,6 +36,29 @@ allow(User, ["read", "export"], Collection, (user, collection) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
allow(User, "share", Collection, (user, collection) => {
|
||||||
|
if (!collection || user.teamId !== collection.teamId) return false;
|
||||||
|
if (!collection.sharing) return false;
|
||||||
|
|
||||||
|
if (collection.private) {
|
||||||
|
invariant(
|
||||||
|
collection.memberships,
|
||||||
|
"membership should be preloaded, did you forget withMembership scope?"
|
||||||
|
);
|
||||||
|
|
||||||
|
const allMemberships = concat(
|
||||||
|
collection.memberships,
|
||||||
|
collection.collectionGroupMemberships
|
||||||
|
);
|
||||||
|
|
||||||
|
return some(allMemberships, (m) =>
|
||||||
|
["read_write", "maintainer"].includes(m.permission)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
allow(User, ["publish", "update"], Collection, (user, collection) => {
|
allow(User, ["publish", "update"], Collection, (user, collection) => {
|
||||||
if (!collection || user.teamId !== collection.teamId) return false;
|
if (!collection || user.teamId !== collection.teamId) return false;
|
||||||
|
|
||||||
|
@ -31,12 +31,22 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
|
|||||||
return user.teamId === document.teamId;
|
return user.teamId === document.teamId;
|
||||||
});
|
});
|
||||||
|
|
||||||
allow(User, ["update", "share"], Document, (user, document) => {
|
allow(User, "share", Document, (user, document) => {
|
||||||
if (document.archivedAt) return false;
|
if (document.archivedAt) return false;
|
||||||
if (document.deletedAt) return false;
|
if (document.deletedAt) return false;
|
||||||
|
|
||||||
// existence of collection option is not required here to account for share tokens
|
if (cannot(user, "share", document.collection)) {
|
||||||
if (document.collection && cannot(user, "update", document.collection)) {
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, "update", Document, (user, document) => {
|
||||||
|
if (document.archivedAt) return false;
|
||||||
|
if (document.deletedAt) return false;
|
||||||
|
|
||||||
|
if (cannot(user, "update", document.collection)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,6 +30,7 @@ export default function present(collection: Collection) {
|
|||||||
icon: collection.icon,
|
icon: collection.icon,
|
||||||
color: collection.color || "#4E5C6E",
|
color: collection.color || "#4E5C6E",
|
||||||
private: collection.private,
|
private: collection.private,
|
||||||
|
sharing: collection.sharing,
|
||||||
createdAt: collection.createdAt,
|
createdAt: collection.createdAt,
|
||||||
updatedAt: collection.updatedAt,
|
updatedAt: collection.updatedAt,
|
||||||
deletedAt: collection.deletedAt,
|
deletedAt: collection.deletedAt,
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
href="/favicon-32.png"
|
href="/favicon-32.png"
|
||||||
sizes="32x32"
|
sizes="32x32"
|
||||||
/>
|
/>
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||||
<link
|
<link
|
||||||
rel="search"
|
rel="search"
|
||||||
type="application/opensearchdescription+xml"
|
type="application/opensearchdescription+xml"
|
||||||
|
@ -86,8 +86,6 @@
|
|||||||
"Change Language": "Change Language",
|
"Change Language": "Change Language",
|
||||||
"Dismiss": "Dismiss",
|
"Dismiss": "Dismiss",
|
||||||
"Keyboard shortcuts": "Keyboard shortcuts",
|
"Keyboard shortcuts": "Keyboard shortcuts",
|
||||||
"Expand": "Expand",
|
|
||||||
"Collapse": "Collapse",
|
|
||||||
"New collection": "New collection",
|
"New collection": "New collection",
|
||||||
"Collections": "Collections",
|
"Collections": "Collections",
|
||||||
"Untitled": "Untitled",
|
"Untitled": "Untitled",
|
||||||
@ -110,7 +108,8 @@
|
|||||||
"Import / Export": "Import / Export",
|
"Import / Export": "Import / Export",
|
||||||
"Integrations": "Integrations",
|
"Integrations": "Integrations",
|
||||||
"Installation": "Installation",
|
"Installation": "Installation",
|
||||||
"Resize sidebar": "Resize sidebar",
|
"Expand": "Expand",
|
||||||
|
"Collapse": "Collapse",
|
||||||
"Unstar": "Unstar",
|
"Unstar": "Unstar",
|
||||||
"Star": "Star",
|
"Star": "Star",
|
||||||
"Appearance": "Appearance",
|
"Appearance": "Appearance",
|
||||||
@ -131,10 +130,9 @@
|
|||||||
"New document": "New document",
|
"New document": "New document",
|
||||||
"Import document": "Import document",
|
"Import document": "Import document",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
"Permissions": "Permissions",
|
|
||||||
"Export": "Export",
|
"Export": "Export",
|
||||||
"Delete": "Delete",
|
"Delete": "Delete",
|
||||||
"Collection permissions": "Collection permissions",
|
"Collection members": "Collection members",
|
||||||
"Edit collection": "Edit collection",
|
"Edit collection": "Edit collection",
|
||||||
"Delete collection": "Delete collection",
|
"Delete collection": "Delete collection",
|
||||||
"Export collection": "Export collection",
|
"Export collection": "Export collection",
|
||||||
@ -211,6 +209,9 @@
|
|||||||
"Alphabetical": "Alphabetical",
|
"Alphabetical": "Alphabetical",
|
||||||
"Private collection": "Private collection",
|
"Private collection": "Private collection",
|
||||||
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
|
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
|
||||||
|
"Public document sharing": "Public document sharing",
|
||||||
|
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
|
||||||
|
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
|
||||||
"Saving": "Saving",
|
"Saving": "Saving",
|
||||||
"Save": "Save",
|
"Save": "Save",
|
||||||
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
|
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
|
||||||
@ -231,6 +232,7 @@
|
|||||||
"No people left to add": "No people left to add",
|
"No people left to add": "No people left to add",
|
||||||
"Read only": "Read only",
|
"Read only": "Read only",
|
||||||
"Read & Edit": "Read & Edit",
|
"Read & Edit": "Read & Edit",
|
||||||
|
"Permissions": "Permissions",
|
||||||
"Active <1></1> ago": "Active <1></1> ago",
|
"Active <1></1> ago": "Active <1></1> ago",
|
||||||
"Never signed in": "Never signed in",
|
"Never signed in": "Never signed in",
|
||||||
"Invited": "Invited",
|
"Invited": "Invited",
|
||||||
@ -319,6 +321,12 @@
|
|||||||
"Export Requested": "Export Requested",
|
"Export Requested": "Export Requested",
|
||||||
"Requesting Export": "Requesting Export",
|
"Requesting Export": "Requesting Export",
|
||||||
"Export Data": "Export Data",
|
"Export Data": "Export Data",
|
||||||
|
"Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.",
|
||||||
|
"Active": "Active",
|
||||||
|
"Admins": "Admins",
|
||||||
|
"Suspended": "Suspended",
|
||||||
|
"Everyone": "Everyone",
|
||||||
|
"No people to see here.": "No people to see here.",
|
||||||
"Profile saved": "Profile saved",
|
"Profile saved": "Profile saved",
|
||||||
"Profile picture updated": "Profile picture updated",
|
"Profile picture updated": "Profile picture updated",
|
||||||
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
||||||
@ -336,7 +344,6 @@
|
|||||||
"You joined": "You joined",
|
"You joined": "You joined",
|
||||||
"Joined": "Joined",
|
"Joined": "Joined",
|
||||||
"{{ time }} ago.": "{{ time }} ago.",
|
"{{ time }} ago.": "{{ time }} ago.",
|
||||||
"Suspended": "Suspended",
|
|
||||||
"Edit Profile": "Edit Profile",
|
"Edit Profile": "Edit Profile",
|
||||||
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet."
|
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet."
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user