mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c594423f | |||
| 2bf237d54b | |||
| 9a1c8f07d1 | |||
| 241cb11493 | |||
| 8195791bb2 | |||
| b037ae5dc1 | |||
| aeba8ce4eb | |||
| 3565e68725 | |||
| 429c5fba85 | |||
| 9495ddba25 | |||
| 486a60e97c | |||
| c687745263 | |||
| 1b92993b90 | |||
| 181a20a268 | |||
| f8ffa4e25a | |||
| 7e139ca8f7 | |||
| bb58db507d | |||
| 49bf86d6d9 | |||
| 286a15cf10 | |||
| f65469b777 | |||
| fe65a79d66 | |||
| a1d5ac0907 | |||
| 04eabe68a7 | |||
| 1c0c694c22 | |||
| 2ae74f2834 | |||
| 0f01fc5faa | |||
| 7f1322b7ba | |||
| 3c98133e24 | |||
| 088353d61f | |||
| 31180619e1 | |||
| 9fccc280d7 | |||
| c69b4efc34 | |||
| 61039e9d0d | |||
| 6d09122d56 | |||
| 5fb6097153 | |||
| ec17874568 | |||
| 40c3e9e85f | |||
| 9f739f3788 | |||
| 3cec6b4903 | |||
| ede7f2e3e6 | |||
| f6837b4742 | |||
| 1560e3c9f7 | |||
| ca74908dc5 | |||
| de7ec1119b | |||
| 2093b4297f | |||
| cf8fa5ffa3 | |||
| 1a2a0f4264 | |||
| 5f3a38bf87 | |||
| afff3a6f25 | |||
| b5824879a3 | |||
| 1c82e292e0 | |||
| 317289ac2a | |||
| 8331026cb3 | |||
| de285f2b63 | |||
| d205c48296 | |||
| 277c37dae6 | |||
| 2c39cd6496 | |||
| d85592b5f3 | |||
| cdf0df0faa | |||
| 48f54b5aa2 | |||
| 2ca57fc7cf | |||
| 470920e2c3 | |||
| beee8ebee7 | |||
| 9f05c9bd43 | |||
| 65be808556 | |||
| 89f8df619c | |||
| 756ec92cdb | |||
| a8e2e349e9 | |||
| 25f745e7e5 | |||
| 07b1811993 | |||
| d71f0ae6bd | |||
| f58032d305 | |||
| 6beb6febc4 | |||
| a6d4d4ea36 | |||
| a99f6bed42 | |||
| 4cd61db1ea | |||
| 0db7bb7f3e | |||
| d8ca9c6111 | |||
| 4a8d357084 | |||
| e0fb76cb63 | |||
| ffed38bf71 | |||
| b4c08a027b | |||
| 74e0f4dfb3 | |||
| 5c7f2cf164 | |||
| f517a2cecb | |||
| a19ac6aa5f | |||
| ddbbb963b6 | |||
| ba24a3318e | |||
| 7a6491cf0d | |||
| 0c8d4428fc | |||
| b19fd799ef | |||
| 082ced3072 | |||
| 1f49b35c89 | |||
| 9817e2f3bf | |||
| 04d7c7ac0e | |||
| e625e77a56 | |||
| 636023aceb | |||
| f2dfed4c72 | |||
| 8cfa724200 | |||
| 6c011eb9b5 | |||
| 7dc11e5b86 | |||
| 44920a25f4 | |||
| dc4b5588b7 | |||
| 635910195b | |||
| eaf2e50af8 | |||
| 505ed3403a | |||
| b93d15e967 | |||
| 028eb72f9c | |||
| b0196f0cf0 | |||
| 833bd51f4c | |||
| 14d9adefe7 | |||
| ec3ea09b2d | |||
| 2c0f14f07b | |||
| a93d034091 | |||
| 447371f35a | |||
| 3bd56fff9e | |||
| 9d03c89c02 | |||
| 9f226cf3b4 | |||
| d01e3f7c72 | |||
| 2cb0bab82a | |||
| 456a7e497b | |||
| a39f7a4e55 | |||
| fed3774cee | |||
| 985f0da674 | |||
| 721e7466e6 | |||
| 8e1d9f0a7d | |||
| 71de0c7e5f | |||
| 4f4067c449 | |||
| b945b614f8 | |||
| 896ee5c20d | |||
| e984a3dcdb | |||
| 69802cc985 | |||
| 6ef8d9ddb3 | |||
| d21594a6f4 | |||
| 974d6b2cbe | |||
| 3df82c500b |
+12
-6
@@ -8,12 +8,12 @@
|
||||
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
|
||||
# DO NOT LEAVE UNSET
|
||||
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
|
||||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
|
||||
# DO NOT LEAVE UNSET
|
||||
# Generate a unique random key. The format is not important but you could still use
|
||||
# `openssl rand -hex 32` in your terminal to produce this.
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
@@ -94,9 +94,13 @@ FORCE_HTTPS=true
|
||||
# the maintainers
|
||||
ENABLE_UPDATES=true
|
||||
|
||||
# Override the maxium size of document imports, could be required if you have
|
||||
# especially large Word documents with embedded imagery
|
||||
MAXIMUM_IMPORT_SIZE=5120000
|
||||
|
||||
# You may enable or disable debugging categories to increase the noisiness of
|
||||
# logs. The default is a good balance
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,services
|
||||
|
||||
# Comma separated list of domains to be allowed to signin to the wiki. If not
|
||||
# set, all domains are allowed by default when using Google OAuth to signin
|
||||
@@ -124,10 +128,12 @@ SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
daysUntilStale: 90
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
daysUntilClose: 14
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
|
||||
@@ -96,7 +96,8 @@ For contributing features and fixes you can quickly get an environment running u
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
@@ -135,6 +135,15 @@
|
||||
"description": "wikireply@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SECURE": {
|
||||
"value": "true",
|
||||
"description": "Use a secure SMTP connection (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_TLS_CIPHERS": {
|
||||
"description": "Override SMTP cipher configuration (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"testURL": "http://localhost",
|
||||
"verbose": false,
|
||||
"rootDir": "..",
|
||||
"roots": [
|
||||
"<rootDir>/app",
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"jsx",
|
||||
"json"
|
||||
],
|
||||
"moduleDirectories": [
|
||||
"node_modules"
|
||||
],
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./app/test/setup.js"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,9 @@ type Props = {
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) return;
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
@@ -16,7 +15,7 @@ type Props = {
|
||||
isPresent: boolean,
|
||||
isEditing: boolean,
|
||||
isCurrentUser: boolean,
|
||||
lastViewedAt: string,
|
||||
profileOnClick: boolean,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@@ -33,22 +32,13 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
lastViewedAt,
|
||||
isPresent,
|
||||
isEditing,
|
||||
isCurrentUser,
|
||||
t,
|
||||
} = this.props;
|
||||
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
|
||||
|
||||
const action = isPresent
|
||||
? isEditing
|
||||
? t("currently editing")
|
||||
: t("currently viewing")
|
||||
: t("viewed {{ timeAgo }} ago", {
|
||||
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
|
||||
});
|
||||
: t("previously edited");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -56,8 +46,12 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
||||
<br />
|
||||
{action}
|
||||
{action && (
|
||||
<>
|
||||
<br />
|
||||
{action}
|
||||
</>
|
||||
)}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
@@ -65,7 +59,11 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
<AvatarWrapper isPresent={isPresent}>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={this.handleOpenProfile}
|
||||
onClick={
|
||||
this.props.profileOnClick === false
|
||||
? undefined
|
||||
: this.handleOpenProfile
|
||||
}
|
||||
size={32}
|
||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||
/>
|
||||
|
||||
+52
-158
@@ -1,193 +1,87 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "hooks/useStores";
|
||||
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
children?: React.Node,
|
||||
onlyText: boolean,
|
||||
type MenuItem = {|
|
||||
icon?: React.Node,
|
||||
title: React.Node,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
function Icon({ document }) {
|
||||
const { t } = useTranslation();
|
||||
type Props = {|
|
||||
items: MenuItem[],
|
||||
max?: number,
|
||||
children?: React.Node,
|
||||
highlightFirstItem?: boolean,
|
||||
|};
|
||||
|
||||
if (document.isDeleted) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/trash">
|
||||
<TrashIcon color="currentColor" />
|
||||
|
||||
<span>{t("Trash")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>{t("Archive")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isDraft) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>{t("Drafts")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isTemplate) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>{t("Templates")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
|
||||
const totalItems = items.length;
|
||||
let topLevelItems: MenuItem[] = [...items];
|
||||
let overflowItems;
|
||||
|
||||
const Breadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return;
|
||||
// chop middle breadcrumbs and present a "..." menu instead
|
||||
if (totalItems > max) {
|
||||
const halfMax = Math.floor(max / 2);
|
||||
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
|
||||
topLevelItems.splice(halfMax, 0, {
|
||||
title: <BreadcrumbMenu items={overflowItems} />,
|
||||
});
|
||||
}
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
color: "currentColor",
|
||||
};
|
||||
}
|
||||
|
||||
const path = collection.pathToDocument
|
||||
? collection.pathToDocument(document.id).slice(0, -1)
|
||||
: [];
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<>
|
||||
{collection.name}
|
||||
{path.map((n) => (
|
||||
<React.Fragment key={n.id}>
|
||||
<SmallSlash />
|
||||
{n.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isNestedDocument = path.length > 1;
|
||||
const lastPath = path.length ? path[path.length - 1] : undefined;
|
||||
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center">
|
||||
<Icon document={document} />
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
<span>{collection.name}</span>
|
||||
</CollectionName>
|
||||
{isNestedDocument && (
|
||||
<>
|
||||
<Slash /> <BreadcrumbMenu path={menuPath} />
|
||||
</>
|
||||
)}
|
||||
{lastPath && (
|
||||
<>
|
||||
<Slash />{" "}
|
||||
<Crumb to={lastPath.url} title={lastPath.title}>
|
||||
{lastPath.title}
|
||||
</Crumb>
|
||||
</>
|
||||
)}
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={item.to || index}>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
|
||||
fill: ${(props) => props.theme.slate};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
const Item = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const CollectionName = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryName = styled(CollectionName)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(Breadcrumb);
|
||||
export default Breadcrumb;
|
||||
|
||||
@@ -1,79 +1,103 @@
|
||||
// @flow
|
||||
import { sortBy, keyBy } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { sortBy, filter, uniq } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import { AvatarWithPresence } from "components/Avatar";
|
||||
import DocumentViews from "components/DocumentViews";
|
||||
import Facepile from "components/Facepile";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Popover from "components/Popover";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
views: ViewsStore,
|
||||
presence: DocumentPresenceStore,
|
||||
type Props = {|
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!this.props.document.isDeleted) {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { users, presence } = useStores();
|
||||
const { document, currentUserId } = props;
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const collaborators = React.useMemo(
|
||||
() =>
|
||||
sortBy(
|
||||
filter(
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)
|
||||
),
|
||||
(user) => presentIds.includes(user.id)
|
||||
),
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't know about
|
||||
React.useEffect(() => {
|
||||
if (users.isFetching) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { document, presence, views, currentUserId } = this.props;
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const documentViews = views.inDocument(document.id);
|
||||
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const mostRecentViewers = sortBy(
|
||||
documentViews.slice(0, MAX_AVATAR_DISPLAY),
|
||||
(view) => {
|
||||
return presentIds.includes(view.user.id);
|
||||
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
|
||||
if (!users.get(userId)) {
|
||||
return users.fetch(userId);
|
||||
}
|
||||
);
|
||||
});
|
||||
}, [document, users, presentIds, document.collaboratorIds]);
|
||||
|
||||
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<FacepileHiddenOnMobile
|
||||
users={mostRecentViewers.map((v) => v.user)}
|
||||
overflow={overflow}
|
||||
renderAvatar={(user) => {
|
||||
const isPresent = presentIds.includes(user.id);
|
||||
const isEditing = editingIds.includes(user.id);
|
||||
const { lastViewedAt } = viewersKeyedByUserId[user.id];
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
||||
<FacepileHiddenOnMobile
|
||||
users={collaborators}
|
||||
renderAvatar={(user) => {
|
||||
const isPresent = presentIds.includes(user.id);
|
||||
const isEditing = editingIds.includes(user.id);
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
lastViewedAt={lastViewedAt}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
profileOnClick={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</NudeButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FacepileHiddenOnMobile = styled(Facepile)`
|
||||
@@ -82,4 +106,4 @@ const FacepileHiddenOnMobile = styled(Facepile)`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject("views", "presence")(Collaborators);
|
||||
export default observer(Collaborators);
|
||||
|
||||
@@ -29,15 +29,26 @@ const MenuItem = ({
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClick(ev);
|
||||
}
|
||||
|
||||
if (hide) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
[hide, onClick]
|
||||
[onClick, hide]
|
||||
);
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
@@ -48,12 +59,14 @@ const MenuItem = ({
|
||||
{(props) => (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$toggleable={selected !== undefined}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
@@ -64,9 +77,10 @@ const MenuItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
@@ -99,7 +113,8 @@ export const MenuAnchor = styled.a`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
@@ -110,15 +125,10 @@ export const MenuAnchor = styled.a`
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 6px 12px;
|
||||
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
|
||||
font-size: 15px;
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
@@ -101,7 +101,11 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered.map((item, index) => {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
return filterTemplateItems(items).map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<MenuItem
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import { Menu } from "reakit/Menu";
|
||||
@@ -47,7 +46,7 @@ export default function ContextMenu({
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
<Background dir="auto">
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
@@ -114,8 +113,7 @@ const Background = styled.div`
|
||||
transform-origin: ${(props) =>
|
||||
props.left !== undefined ? "25%" : "75%"} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
|
||||
backdrop-filter: blur(20px);
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
|
||||
@@ -15,6 +15,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const elem = React.Children.only(children);
|
||||
copy(text, {
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
if (onCopy) onCopy();
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import useStores from "hooks/useStores";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
children?: React.Node,
|
||||
onlyText: boolean,
|
||||
|};
|
||||
|
||||
function useCategory(document) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return {
|
||||
icon: <TrashIcon color="currentColor" />,
|
||||
title: t("Trash"),
|
||||
to: "/trash",
|
||||
};
|
||||
}
|
||||
if (document.isArchived) {
|
||||
return {
|
||||
icon: <ArchiveIcon color="currentColor" />,
|
||||
title: t("Archive"),
|
||||
to: "/archive",
|
||||
};
|
||||
}
|
||||
if (document.isDraft) {
|
||||
return {
|
||||
icon: <EditIcon color="currentColor" />,
|
||||
title: t("Drafts"),
|
||||
to: "/drafts",
|
||||
};
|
||||
}
|
||||
if (document.isTemplate) {
|
||||
return {
|
||||
icon: <ShapesIcon color="currentColor" />,
|
||||
title: t("Templates"),
|
||||
to: "/templates",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
color: "currentColor",
|
||||
url: "deleted-collection",
|
||||
};
|
||||
}
|
||||
|
||||
const path = React.useMemo(
|
||||
() =>
|
||||
collection && collection.pathToDocument
|
||||
? collection.pathToDocument(document.id).slice(0, -1)
|
||||
: [],
|
||||
[collection, document.id]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
let output = [];
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
output.push({
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
title: collection.name,
|
||||
to: collectionUrl(collection.url),
|
||||
});
|
||||
}
|
||||
|
||||
path.forEach((p) => {
|
||||
output.push({
|
||||
title: p.title,
|
||||
to: p.url,
|
||||
});
|
||||
});
|
||||
|
||||
return output;
|
||||
}, [path, category, collection]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<>
|
||||
{collection.name}
|
||||
{path.map((n) => (
|
||||
<React.Fragment key={n.id}>
|
||||
<SmallSlash />
|
||||
{n.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <Breadcrumb items={items} children={children} highlightFirstItem />;
|
||||
};
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
|
||||
fill: ${(props) => props.theme.slate};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
export default observer(DocumentBreadcrumb);
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -37,7 +37,7 @@ class RevisionListItem extends React.Component<Props> {
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt} tooltipDelay={250}>
|
||||
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
|
||||
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
{showMenu && (
|
||||
|
||||
@@ -41,7 +41,7 @@ function replaceResultMarks(tag: string) {
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props) {
|
||||
function DocumentListItem(props: Props, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
@@ -68,6 +68,8 @@ function DocumentListItem(props: Props) {
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
@@ -76,8 +78,12 @@ function DocumentListItem(props: Props) {
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
@@ -221,6 +227,7 @@ const DocumentLink = styled(Link)`
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
@@ -251,4 +258,4 @@ const ResultContext = styled(Highlight)`
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(DocumentListItem);
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
|
||||
@@ -5,12 +5,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
@@ -135,14 +136,14 @@ function DocumentMeta({
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,38 +1,69 @@
|
||||
// @flow
|
||||
import { useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import DocumentViews from "components/DocumentViews";
|
||||
import Popover from "components/Popover";
|
||||
import useStores from "../hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
to?: string,
|
||||
rtl?: boolean,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!document.isDeleted) {
|
||||
views.fetchPage({ documentId: document.id });
|
||||
}
|
||||
}, [views, document.id, document.isDeleted]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 8,
|
||||
placement: "bottom",
|
||||
modal: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to}>
|
||||
<Meta document={document} to={to} {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<>
|
||||
· Viewed by{" "}
|
||||
{onlyYou
|
||||
? "only you"
|
||||
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
|
||||
</>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<>
|
||||
·
|
||||
<a {...props}>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
) : null}
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// @flow
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "models/Document";
|
||||
import Avatar from "components/Avatar";
|
||||
import ListItem from "components/List/Item";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
isOpen?: boolean,
|
||||
|};
|
||||
|
||||
function DocumentViews({ document, isOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { views, presence } = useStores();
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const documentViews = views.inDocument(document.id);
|
||||
const sortedViews = sortBy(
|
||||
documentViews,
|
||||
(view) => !presentIds.includes(view.user.id)
|
||||
);
|
||||
|
||||
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
|
||||
sortedViews,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
items={users}
|
||||
renderItem={(item) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
const isPresent = presentIds.includes(item.id);
|
||||
const isEditing = editingIds.includes(item.id);
|
||||
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: formatDistanceToNow(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentViews);
|
||||
@@ -4,15 +4,20 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { light } from "shared/styles/theme";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import { type Theme } from "types";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
const RichMarkdownEditor = React.lazy(() =>
|
||||
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
|
||||
);
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
@@ -24,11 +29,13 @@ export type Props = {|
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
shareId?: ?string,
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
maxLength?: number,
|
||||
scrollTo?: string,
|
||||
theme?: Theme,
|
||||
handleDOMEvents?: Object,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
@@ -51,8 +58,9 @@ type PropsWithRef = Props & {
|
||||
};
|
||||
|
||||
function Editor(props: PropsWithRef) {
|
||||
const { id, ui, history } = props;
|
||||
const { id, ui, shareId, history } = props;
|
||||
const { t } = useTranslation();
|
||||
const isPrinting = useMediaQuery("print");
|
||||
|
||||
const onUploadImage = React.useCallback(
|
||||
async (file: File) => {
|
||||
@@ -84,12 +92,16 @@ function Editor(props: PropsWithRef) {
|
||||
}
|
||||
}
|
||||
|
||||
if (shareId) {
|
||||
navigateTo = `/share/${shareId}${navigateTo}`;
|
||||
}
|
||||
|
||||
history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[history]
|
||||
[history, shareId]
|
||||
);
|
||||
|
||||
const onShowToast = React.useCallback(
|
||||
@@ -122,6 +134,7 @@ function Editor(props: PropsWithRef) {
|
||||
deleteRow: t("Delete row"),
|
||||
deleteTable: t("Delete table"),
|
||||
deleteImage: t("Delete image"),
|
||||
downloadImage: t("Download image"),
|
||||
alignImageLeft: t("Float left"),
|
||||
alignImageRight: t("Float right"),
|
||||
alignImageDefault: t("Center large"),
|
||||
@@ -175,6 +188,7 @@ function Editor(props: PropsWithRef) {
|
||||
tooltip={EditorTooltip}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
theme={isPrinting ? light : props.theme}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
+27
-31
@@ -1,45 +1,41 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
users: User[],
|
||||
size?: number,
|
||||
overflow: number,
|
||||
renderAvatar: (user: User) => React.Node,
|
||||
};
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
renderAvatar?: (user: User) => React.Node,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Facepile extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
users,
|
||||
overflow,
|
||||
size = 32,
|
||||
renderAvatar = renderDefaultAvatar,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<More size={size}>
|
||||
<span>+{overflow}</span>
|
||||
</More>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
function Facepile({
|
||||
users,
|
||||
overflow,
|
||||
size = 32,
|
||||
renderAvatar = DefaultAvatar,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<More size={size}>
|
||||
<span>+{overflow}</span>
|
||||
</More>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDefaultAvatar(user: User) {
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||
}
|
||||
|
||||
@@ -73,4 +69,4 @@ const Avatars = styled(Flex)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default inject("views", "presence")(withTheme(Facepile));
|
||||
export default observer(Facepile);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "components/Button";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import FilterOption from "./FilterOption";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import HelpText from "components/HelpText";
|
||||
|
||||
type TFilterOption = {|
|
||||
key: string,
|
||||
@@ -35,7 +36,7 @@ const FilterOptions = ({
|
||||
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
|
||||
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Wrapper>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
@@ -50,30 +51,49 @@ const FilterOptions = ({
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} {...menu}>
|
||||
<List>
|
||||
{options.map((option) => (
|
||||
<FilterOption
|
||||
key={option.key}
|
||||
onSelect={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
active={option.key === activeKey}
|
||||
{...option}
|
||||
{...menu}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={option.key === activeKey}
|
||||
{...menu}
|
||||
>
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</SearchFilter>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const Note = styled(HelpText)`
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
border-color: transparent;
|
||||
height: 28px;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
@@ -84,14 +104,8 @@ const StyledButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchFilter = styled.div`
|
||||
const Wrapper = styled.div`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const List = styled("ol")`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
export default FilterOptions;
|
||||
@@ -25,6 +25,7 @@ type Props = {|
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
role?: string,
|
||||
gap?: number,
|
||||
|};
|
||||
|
||||
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
|
||||
@@ -44,6 +45,7 @@ const Container = styled.div`
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
gap: ${({ gap }) => `${gap}px` || "initial"};
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
@@ -32,7 +32,10 @@ import NudeButton from "components/NudeButton";
|
||||
const style = { width: 30, height: 30 };
|
||||
|
||||
const TwitterPicker = React.lazy(() =>
|
||||
import("react-color/lib/components/twitter/Twitter")
|
||||
import(
|
||||
/* webpackChunkName: "twitter-picker" */
|
||||
"react-color/lib/components/twitter/Twitter"
|
||||
)
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
|
||||
@@ -3,13 +3,14 @@ import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import Input, { type Props as InputProps } from "./Input";
|
||||
|
||||
type Props = {|
|
||||
...InputProps,
|
||||
placeholder?: string,
|
||||
value?: string,
|
||||
onChange: (event: SyntheticInputEvent<>) => mixed,
|
||||
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
|};
|
||||
|
||||
export default function InputSearch(props: Props) {
|
||||
|
||||
@@ -16,6 +16,10 @@ const Select = styled.select`
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
option {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
|
||||
@@ -202,7 +202,7 @@ const Content = styled(Flex)`
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
|
||||
@media print {
|
||||
margin: 0;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
|
||||
+26
-11
@@ -8,17 +8,26 @@ type Props = {
|
||||
title: React.Node,
|
||||
subtitle?: React.Node,
|
||||
actions?: React.Node,
|
||||
border?: boolean,
|
||||
small?: boolean,
|
||||
};
|
||||
|
||||
const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
||||
const ListItem = ({
|
||||
image,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
small,
|
||||
border,
|
||||
}: Props) => {
|
||||
const compact = !subtitle;
|
||||
|
||||
return (
|
||||
<Wrapper compact={compact}>
|
||||
<Wrapper compact={compact} $border={border}>
|
||||
{image && <Image>{image}</Image>}
|
||||
<Content align={compact ? "center" : undefined} column={!compact}>
|
||||
<Heading>{title}</Heading>
|
||||
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||
<Heading $small={small}>{title}</Heading>
|
||||
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
|
||||
</Content>
|
||||
{actions && <Actions>{actions}</Actions>}
|
||||
</Wrapper>
|
||||
@@ -27,9 +36,11 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
||||
|
||||
const Wrapper = styled.li`
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false ? "transparent" : props.theme.divider};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
@@ -46,9 +57,12 @@ const Image = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Heading = styled.p`
|
||||
font-size: 16px;
|
||||
font-size: ${(props) => (props.$small ? 15 : 16)}px;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
@@ -58,8 +72,9 @@ const Content = styled(Flex)`
|
||||
|
||||
const Subtitle = styled.p`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.slate};
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
enUS,
|
||||
de,
|
||||
fr,
|
||||
es,
|
||||
it,
|
||||
ko,
|
||||
ptBR,
|
||||
pt,
|
||||
zhCN,
|
||||
zhTW,
|
||||
ru,
|
||||
} from "date-fns/locale";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
|
||||
const locales = {
|
||||
en: require(`date-fns/locale/en`),
|
||||
de: require(`date-fns/locale/de`),
|
||||
es: require(`date-fns/locale/es`),
|
||||
fr: require(`date-fns/locale/fr`),
|
||||
it: require(`date-fns/locale/it`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
ru: require(`date-fns/locale/ru`),
|
||||
en_US: enUS,
|
||||
de_DE: de,
|
||||
es_ES: es,
|
||||
fr_FR: fr,
|
||||
it_IT: it,
|
||||
ko_KR: ko,
|
||||
pt_BR: ptBR,
|
||||
pt_PT: pt,
|
||||
zh_CN: zhCN,
|
||||
zh_TW: zhTW,
|
||||
ru_RU: ru,
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
@@ -64,7 +78,7 @@ function LocaleTime({
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = distanceInWordsToNow(dateTime, {
|
||||
let content = formatDistanceToNow(Date.parse(dateTime), {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
});
|
||||
@@ -78,7 +92,7 @@ function LocaleTime({
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -8,6 +8,8 @@ import Flex from "components/Flex";
|
||||
type Props = {|
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
minWidth?: number,
|
||||
maxWidth?: number,
|
||||
|};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
@@ -17,9 +19,9 @@ class Mask extends React.Component<Props> {
|
||||
return false;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
constructor(props: Props) {
|
||||
super();
|
||||
this.width = randomInteger(75, 100);
|
||||
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -3,8 +3,8 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.button`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
width: ${(props) => props.width || props.size}px;
|
||||
height: ${(props) => props.height || props.size}px;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
|
||||
@@ -38,14 +38,24 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.fetch !== this.props.fetch) {
|
||||
this.fetchResults();
|
||||
}
|
||||
if (!isEqual(prevProps.options, this.props.options)) {
|
||||
if (
|
||||
prevProps.fetch !== this.props.fetch ||
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
) {
|
||||
this.reset();
|
||||
this.fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.offset = 0;
|
||||
this.allowLoadMore = true;
|
||||
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
this.isLoaded = false;
|
||||
};
|
||||
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
import "../stores";
|
||||
import { shallow } from "enzyme";
|
||||
import * as React from "react";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import { runAllPromises } from "../test/support";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const render = () => null;
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
|
||||
expect(list).toEqual({});
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", () => {
|
||||
const list = shallow(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
/>
|
||||
);
|
||||
expect(list.text()).toEqual("Sorry, no results");
|
||||
});
|
||||
|
||||
it("calls fetch with options + pagination on mount", () => {
|
||||
const fetch = jest.fn();
|
||||
const options = { id: "one" };
|
||||
|
||||
shallow(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
/>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
limit: DEFAULT_PAGINATION_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls fetch when options prop changes", async () => {
|
||||
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
|
||||
const fetch = jest.fn().mockReturnValue(fetchedItems);
|
||||
|
||||
const list = shallow(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={{ id: "one" }}
|
||||
renderItem={render}
|
||||
/>
|
||||
);
|
||||
|
||||
await runAllPromises();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
id: "one",
|
||||
limit: DEFAULT_PAGINATION_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
fetch.mockReset();
|
||||
|
||||
list.setProps({
|
||||
fetch,
|
||||
items: [],
|
||||
options: { id: "two" },
|
||||
});
|
||||
|
||||
await runAllPromises();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
id: "two",
|
||||
limit: DEFAULT_PAGINATION_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Popover as ReakitPopover } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
width?: number,
|
||||
};
|
||||
|
||||
function Popover({ children, width = 380, ...rest }: Props) {
|
||||
return (
|
||||
<ReakitPopover {...rest}>
|
||||
<Contents width={width}>{children}</Contents>
|
||||
</ReakitPopover>
|
||||
);
|
||||
}
|
||||
|
||||
const Contents = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 12px 24px;
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
width: ${(props) => props.width}px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`;
|
||||
|
||||
export default Popover;
|
||||
@@ -179,7 +179,7 @@ function MainSidebar() {
|
||||
/>
|
||||
{can.inviteUser && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
to="/settings/members"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
|
||||
@@ -96,10 +96,10 @@ function SettingsSidebar() {
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
to="/settings/members"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
label={t("Members")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
|
||||
@@ -20,6 +20,7 @@ type Props = {
|
||||
|
||||
function Collections({ onCreateCollection }: Props) {
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const { ui, policies, documents, collections } = useStores();
|
||||
const isPreloaded: boolean = !!collections.orderedData.length;
|
||||
const { t } = useTranslation();
|
||||
@@ -32,17 +33,25 @@ function Collections({ onCreateCollection }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
if (!collections.isLoaded && !isFetching) {
|
||||
if (!collections.isLoaded && !isFetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({ limit: 100 });
|
||||
} catch (error) {
|
||||
ui.showToast(
|
||||
t("Collections could not be loaded, please reload the app"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [collections, isFetching]);
|
||||
}, [collections, isFetching, ui, fetchError, t]);
|
||||
|
||||
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
|
||||
accept: "collection",
|
||||
@@ -92,7 +101,7 @@ function Collections({ onCreateCollection }: Props) {
|
||||
</>
|
||||
);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
if (!collections.isLoaded || fetchError) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
|
||||
@@ -27,16 +27,19 @@ type Props = {|
|
||||
parentId?: string,
|
||||
|};
|
||||
|
||||
function DocumentLink({
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
function DocumentLink(
|
||||
{
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -236,6 +239,7 @@ function DocumentLink({
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
ref={ref}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
@@ -282,11 +286,13 @@ const Draggable = styled("div")`
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
const ObservedDocumentLink = observer(DocumentLink);
|
||||
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
|
||||
|
||||
export default ObservedDocumentLink;
|
||||
|
||||
@@ -65,6 +65,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
{isEditing ? (
|
||||
<form onSubmit={handleSave}>
|
||||
<Input
|
||||
dir="auto"
|
||||
type="text"
|
||||
value={value}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -7,7 +7,7 @@ const ResizeBorder = styled.div`
|
||||
bottom: 0;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -29,25 +30,28 @@ type Props = {
|
||||
depth?: number,
|
||||
};
|
||||
|
||||
function SidebarLink({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props) {
|
||||
function SidebarLink(
|
||||
{
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
paddingLeft: `${(depth || 0) * 16 + 16}px`,
|
||||
@@ -78,6 +82,7 @@ function SidebarLink({
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
@@ -139,30 +144,33 @@ const Link = styled(NavLink)`
|
||||
transition: fill 50ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
background: ${(props) =>
|
||||
transparentize("0.25", props.theme.sidebarItemBackground)};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 32px 4px 16px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
@@ -170,6 +178,9 @@ const Label = styled.div`
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 1.6;
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(SidebarLink));
|
||||
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
|
||||
|
||||
@@ -144,9 +144,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
const response = await documents.fetch(documentId, {
|
||||
force: true,
|
||||
});
|
||||
document = response.document;
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
documents.remove(documentId);
|
||||
@@ -249,6 +250,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
documents.starredIds.set(event.documentId, false);
|
||||
});
|
||||
|
||||
this.socket.on("documents.permanent_delete", (event) => {
|
||||
documents.remove(event.documentId);
|
||||
});
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on("collections.add_user", (event) => {
|
||||
|
||||
@@ -61,6 +61,10 @@ export const AnimatedStar = styled(StarredIcon)`
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Star;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTable, useSortBy, usePagination } from "react-table";
|
||||
import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import Mask from "components/Mask";
|
||||
|
||||
export type Props = {|
|
||||
data: any[],
|
||||
offset?: number,
|
||||
isLoading: boolean,
|
||||
empty?: React.Node,
|
||||
currentPage?: number,
|
||||
page: number,
|
||||
pageSize?: number,
|
||||
totalPages?: number,
|
||||
defaultSort?: string,
|
||||
topRef?: React.Ref<any>,
|
||||
onChangePage: (index: number) => void,
|
||||
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
|
||||
columns: any,
|
||||
|};
|
||||
|
||||
function Table({
|
||||
data,
|
||||
offset,
|
||||
isLoading,
|
||||
totalPages,
|
||||
empty,
|
||||
columns,
|
||||
page,
|
||||
pageSize = 50,
|
||||
defaultSort = "name",
|
||||
topRef,
|
||||
onChangeSort,
|
||||
onChangePage,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
canNextPage,
|
||||
nextPage,
|
||||
canPreviousPage,
|
||||
previousPage,
|
||||
state: { pageIndex, sortBy },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
manualPagination: true,
|
||||
manualSortBy: true,
|
||||
autoResetSortBy: false,
|
||||
autoResetPage: false,
|
||||
pageCount: totalPages,
|
||||
initialState: {
|
||||
sortBy: [{ id: defaultSort, desc: false }],
|
||||
pageSize,
|
||||
pageIndex: page,
|
||||
},
|
||||
},
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onChangePage(pageIndex);
|
||||
}, [pageIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onChangePage(0);
|
||||
onChangeSort(
|
||||
sortBy.length ? sortBy[0].id : undefined,
|
||||
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
|
||||
);
|
||||
}, [sortBy]);
|
||||
|
||||
const isEmpty = !isLoading && data.length === 0;
|
||||
const showPlaceholder = isLoading && data.length === 0;
|
||||
|
||||
console.log({ canNextPage, pageIndex, totalPages, rows, data });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Anchor ref={topRef} />
|
||||
<InnerTable {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
|
||||
<SortWrapper align="center" gap={4}>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<Row {...row.getRowProps()}>
|
||||
{row.cells.map((cell) => (
|
||||
<Cell
|
||||
{...cell.getCellProps([
|
||||
{
|
||||
className: cell.column.className,
|
||||
},
|
||||
])}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{showPlaceholder && <Placeholder columns={columns.length} />}
|
||||
</InnerTable>
|
||||
{isEmpty ? (
|
||||
empty || <Empty>{t("No results")}</Empty>
|
||||
) : (
|
||||
<Pagination
|
||||
justify={canPreviousPage ? "space-between" : "flex-end"}
|
||||
gap={8}
|
||||
>
|
||||
{/* Note: the page > 0 check shouldn't be needed here but is */}
|
||||
{canPreviousPage && page > 0 && (
|
||||
<Button onClick={previousPage} neutral>
|
||||
{t("Previous page")}
|
||||
</Button>
|
||||
)}
|
||||
{canNextPage && (
|
||||
<Button onClick={nextPage} neutral>
|
||||
{t("Next page")}
|
||||
</Button>
|
||||
)}
|
||||
</Pagination>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Placeholder = ({
|
||||
columns,
|
||||
rows = 3,
|
||||
}: {
|
||||
columns: number,
|
||||
rows?: number,
|
||||
}) => {
|
||||
return (
|
||||
<tbody>
|
||||
{new Array(rows).fill().map((_, row) => (
|
||||
<Row key={row}>
|
||||
{new Array(columns).fill().map((_, col) => (
|
||||
<Cell key={col}>
|
||||
<Mask minWidth={25} maxWidth={75} />
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</tbody>
|
||||
);
|
||||
};
|
||||
|
||||
const Anchor = styled.div`
|
||||
top: -32px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Pagination = styled(Flex)`
|
||||
margin: 0 0 32px;
|
||||
`;
|
||||
|
||||
const DescSortIcon = styled(CollapsedIcon)`
|
||||
&:hover {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const AscSortIcon = styled(DescSortIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
|
||||
const InnerTable = styled.table`
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SortWrapper = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const Cell = styled.td`
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
font-size: 14px;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.actions,
|
||||
&.right-aligned {
|
||||
text-align: right;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
`;
|
||||
|
||||
const Row = styled.tr`
|
||||
&:last-child {
|
||||
${Cell} {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Head = styled.th`
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 54px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export default observer(Table);
|
||||
@@ -1,8 +1,10 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
|
||||
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
|
||||
const LocaleTime = React.lazy(() =>
|
||||
import(/* webpackChunkName: "locale-time" */ "components/LocaleTime")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
@@ -13,7 +15,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Time(props: Props) {
|
||||
let content = distanceInWordsToNow(props.dateTime, {
|
||||
let content = formatDistanceToNow(Date.parse(props.dateTime), {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class Descript extends React.Component<Props> {
|
||||
static ENABLED = [new RegExp("https?://share.descript.com/view/(\\w+)$")];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props.attrs;
|
||||
const shareId = matches[1];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://share.descript.com/embed/${shareId}`}
|
||||
title={`Descript (${shareId})`}
|
||||
width="400px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ export default class Mindmeister extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const chartId = this.props.attrs.matches[4] + this.props.attrs.matches[6];
|
||||
const chartId =
|
||||
this.props.attrs.matches[4] +
|
||||
(this.props.attrs.matches[5] || "") +
|
||||
(this.props.attrs.matches[6] || "");
|
||||
|
||||
return (
|
||||
<Frame
|
||||
|
||||
@@ -11,9 +11,7 @@ import Flex from "components/Flex";
|
||||
const Iframe = (props) => <iframe title="Embed" {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
@@ -70,13 +68,13 @@ class Frame extends React.Component<PropsWithRef> {
|
||||
<Rounded
|
||||
width={width}
|
||||
height={height}
|
||||
withBar={withBar}
|
||||
$withBar={withBar}
|
||||
className={isSelected ? "ProseMirror-selectednode" : ""}
|
||||
>
|
||||
{this.isLoaded && (
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
withBar={withBar}
|
||||
$withBar={withBar}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -108,10 +106,11 @@ class Frame extends React.Component<PropsWithRef> {
|
||||
}
|
||||
|
||||
const Rounded = styled.div`
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
border: 1px solid ${(props) => props.theme.embedBorder};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
width: ${(props) => props.width};
|
||||
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
|
||||
height: ${(props) => (props.$withBar ? props.height + 28 : props.height)};
|
||||
`;
|
||||
|
||||
const Open = styled.a`
|
||||
@@ -132,11 +131,12 @@ const Title = styled.span`
|
||||
`;
|
||||
|
||||
const Bar = styled(Flex)`
|
||||
border-top: 1px solid ${(props) => props.theme.embedBorder};
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
padding: 0 8px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import Airtable from "./Airtable";
|
||||
import Cawemo from "./Cawemo";
|
||||
import ClickUp from "./ClickUp";
|
||||
import Codepen from "./Codepen";
|
||||
import Descript from "./Descript";
|
||||
import Figma from "./Figma";
|
||||
import Framer from "./Framer";
|
||||
import Gist from "./Gist";
|
||||
@@ -85,6 +86,13 @@ export default [
|
||||
component: Codepen,
|
||||
matcher: matcher(Codepen),
|
||||
},
|
||||
{
|
||||
title: "Descript",
|
||||
keywords: "audio",
|
||||
icon: () => <Img src="/images/descript.png" />,
|
||||
component: Descript,
|
||||
matcher: matcher(Descript),
|
||||
},
|
||||
{
|
||||
title: "Figma",
|
||||
keywords: "design svg vector",
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function useImportDocument(
|
||||
const redirect = files.length === 1;
|
||||
|
||||
if (documentId && !collectionId) {
|
||||
const document = await documents.fetch(documentId);
|
||||
const { document } = await documents.fetch(documentId);
|
||||
invariant(document, "Document not available");
|
||||
cId = document.collectionId;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@ export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
if (window.matchMedia) {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => {
|
||||
setMatches(media.matches);
|
||||
};
|
||||
media.addListener(listener);
|
||||
return () => media.removeListener(listener);
|
||||
}
|
||||
const listener = () => {
|
||||
setMatches(media.matches);
|
||||
};
|
||||
media.addListener(listener);
|
||||
return () => media.removeListener(listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
|
||||
@@ -8,5 +8,5 @@ export default function useUserLocale() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return auth.user.language.split("_")[0];
|
||||
return auth.user.language;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
// Based on https://github.com/rehooks/window-scroll-position which is no longer
|
||||
// maintained.
|
||||
import { throttle } from "lodash";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
let supportsPassive = false;
|
||||
try {
|
||||
var opts = Object.defineProperty({}, "passive", {
|
||||
get: function () {
|
||||
supportsPassive = true;
|
||||
},
|
||||
});
|
||||
window.addEventListener("testPassive", null, opts);
|
||||
window.removeEventListener("testPassive", null, opts);
|
||||
} catch (e) {}
|
||||
|
||||
const getPosition = () => ({
|
||||
x: window.pageXOffset,
|
||||
y: window.pageYOffset,
|
||||
});
|
||||
|
||||
const defaultOptions = {
|
||||
throttle: 100,
|
||||
};
|
||||
|
||||
export default function useWindowScrollPosition(options: {
|
||||
throttle: number,
|
||||
}): { x: number, y: number } {
|
||||
let opts = Object.assign({}, defaultOptions, options);
|
||||
|
||||
let [position, setPosition] = useState(getPosition());
|
||||
|
||||
useEffect(() => {
|
||||
let handleScroll = throttle(() => {
|
||||
setPosition(getPosition());
|
||||
}, opts.throttle);
|
||||
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
handleScroll,
|
||||
supportsPassive ? { passive: true } : false
|
||||
);
|
||||
|
||||
return () => {
|
||||
handleScroll.cancel();
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [opts.throttle]);
|
||||
|
||||
return position;
|
||||
}
|
||||
+20
-18
@@ -51,23 +51,25 @@ if ("serviceWorker" in window.navigator) {
|
||||
|
||||
if (element) {
|
||||
const App = () => (
|
||||
<Provider {...stores}>
|
||||
<Analytics>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
</Provider>
|
||||
<React.StrictMode>
|
||||
<Provider {...stores}>
|
||||
<Analytics>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
render(<App />, element);
|
||||
@@ -79,7 +81,7 @@ window.addEventListener("load", async () => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID || !window.ga) return;
|
||||
|
||||
// https://github.com/googleanalytics/autotrack/issues/137#issuecomment-305890099
|
||||
await import("autotrack/autotrack.js");
|
||||
await import(/* webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
|
||||
|
||||
window.ga("require", "outboundLinkTracker");
|
||||
window.ga("require", "urlChangeTracker");
|
||||
|
||||
@@ -33,7 +33,7 @@ const AppearanceMenu = React.forwardRef((props, ref) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...props}>
|
||||
<MenuButton ref={ref} {...menu} {...props} onClick={menu.show}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
<ChangeTheme justify="space-between">
|
||||
|
||||
@@ -6,11 +6,17 @@ import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
|
||||
type MenuItem = {|
|
||||
icon?: React.Node,
|
||||
title: React.Node,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
type Props = {
|
||||
path: Array<any>,
|
||||
items: MenuItem[],
|
||||
};
|
||||
|
||||
export default function BreadcrumbMenu({ path }: Props) {
|
||||
export default function BreadcrumbMenu({ items }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
@@ -21,13 +27,7 @@ export default function BreadcrumbMenu({ path }: Props) {
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Path to document")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={path.map((item) => ({
|
||||
title: item.title,
|
||||
to: item.url,
|
||||
}))}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
+48
-42
@@ -12,7 +12,7 @@ import CollectionExport from "scenes/CollectionExport";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
@@ -110,6 +110,52 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const can = policies.abilities(collection.id);
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
filterTemplateItems([
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]),
|
||||
[can, collection, handleNewDocument, handleImportDocument, t]
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -134,47 +180,7 @@ function CollectionMenu({
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
|
||||
+64
-39
@@ -9,6 +9,7 @@ import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import DocumentMove from "scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "scenes/DocumentPermanentDelete";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
@@ -61,6 +62,10 @@ function DocumentMenu({
|
||||
const { t } = useTranslation();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [
|
||||
showPermanentDeleteModal,
|
||||
setShowPermanentDeleteModal,
|
||||
] = React.useState(false);
|
||||
const [showMoveModal, setShowMoveModal] = React.useState(false);
|
||||
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
@@ -218,12 +223,7 @@ function DocumentMenu({
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!can.unarchive,
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!(collection && can.restore),
|
||||
visible: (!!collection && can.restore) || can.unarchive,
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
@@ -332,6 +332,11 @@ function DocumentMenu({
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
visible: !!can.delete,
|
||||
},
|
||||
{
|
||||
title: `${t("Permanently delete")}…`,
|
||||
onClick: () => setShowPermanentDeleteModal(true),
|
||||
visible: can.permanentDelete,
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
onClick: () => setShowMoveModal(true),
|
||||
@@ -362,40 +367,60 @@ function DocumentMenu({
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
{can.move && (
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={() => setShowTemplateModal(false)}
|
||||
isOpen={showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={document}
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.delete && (
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.permanentDelete && (
|
||||
<Modal
|
||||
title={t("Permanently delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowPermanentDeleteModal(false)}
|
||||
isOpen={showPermanentDeleteModal}
|
||||
>
|
||||
<DocumentPermanentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowPermanentDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={() => setShowTemplateModal(false)}
|
||||
isOpen={showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={document}
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ function NewDocumentMenu() {
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (singleCollection) {
|
||||
|
||||
@@ -23,7 +23,7 @@ function NewTemplateMenu() {
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+32
-16
@@ -9,6 +9,7 @@ import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
@@ -19,12 +20,36 @@ function TemplatesMenu({ document }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const templates = documents.templatesInCollection(document.collectionId);
|
||||
const templates = documents.templates;
|
||||
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templatesInCollection = templates.filter(
|
||||
(t) => t.collectionId === document.collectionId
|
||||
);
|
||||
const otherTemplates = templates.filter(
|
||||
(t) => t.collectionId !== document.collectionId
|
||||
);
|
||||
|
||||
const renderTemplate = (template) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
{...menu}
|
||||
>
|
||||
<DocumentIcon />
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
@@ -35,21 +60,11 @@ function TemplatesMenu({ document }: Props) {
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Templates")}>
|
||||
{templates.map((template) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
>
|
||||
<DocumentIcon />
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
{templatesInCollection.map(renderTemplate)}
|
||||
{otherTemplates.length && templatesInCollection.length ? (
|
||||
<Separator />
|
||||
) : undefined}
|
||||
{otherTemplates.map(renderTemplate)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
@@ -57,6 +72,7 @@ function TemplatesMenu({ document }: Props) {
|
||||
|
||||
const Author = styled.div`
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
export default observer(TemplatesMenu);
|
||||
|
||||
@@ -24,6 +24,7 @@ export default class Collection extends BaseModel {
|
||||
deletedAt: ?string;
|
||||
sort: { field: string, direction: "asc" | "desc" };
|
||||
url: string;
|
||||
urlId: string;
|
||||
|
||||
@computed
|
||||
get isEmpty(): boolean {
|
||||
|
||||
+21
-2
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import addDays from "date-fns/add_days";
|
||||
import differenceInDays from "date-fns/difference_in_days";
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { action, computed, observable, set } from "mobx";
|
||||
import parseTitle from "shared/utils/parseTitle";
|
||||
@@ -59,6 +58,26 @@ export default class Document extends BaseModel {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-guess the text direction of the document based on the language the
|
||||
* title is written in. Note: wrapping as a computed getter means that it will
|
||||
* only be called directly when the title changes.
|
||||
*/
|
||||
@computed
|
||||
get dir(): "rtl" | "ltr" {
|
||||
const element = document.createElement("p");
|
||||
element.innerHTML = this.title;
|
||||
element.style.visibility = "hidden";
|
||||
element.dir = "auto";
|
||||
|
||||
// element must appear in body for direction to be computed
|
||||
document.body?.appendChild(element);
|
||||
|
||||
const direction = window.getComputedStyle(element).direction;
|
||||
document.body?.removeChild(element);
|
||||
return direction;
|
||||
}
|
||||
|
||||
@computed
|
||||
get noun(): string {
|
||||
return this.template ? "template" : "document";
|
||||
|
||||
@@ -10,6 +10,7 @@ class Share extends BaseModel {
|
||||
documentTitle: string;
|
||||
documentUrl: string;
|
||||
lastAccessedAt: ?string;
|
||||
includeChildDocuments: boolean;
|
||||
createdBy: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
+45
-38
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import { Switch, Redirect, type Match } from "react-router-dom";
|
||||
import Archive from "scenes/Archive";
|
||||
import Collection from "scenes/Collection";
|
||||
import KeyedDocument from "scenes/Document/KeyedDocument";
|
||||
import DocumentNew from "scenes/DocumentNew";
|
||||
import Drafts from "scenes/Drafts";
|
||||
import Error404 from "scenes/Error404";
|
||||
@@ -20,8 +19,14 @@ import Route from "components/ProfiledRoute";
|
||||
import SocketProvider from "components/SocketProvider";
|
||||
import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
|
||||
const SettingsRoutes = React.lazy(() => import("./settings"));
|
||||
|
||||
const SettingsRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "settings" */ "./settings")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
);
|
||||
const NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
<Redirect
|
||||
@@ -35,42 +40,44 @@ export default function AuthenticatedRoutes() {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Route exact path="/collections/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collections/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collections/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collection/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
|
||||
<SettingsRoutes />
|
||||
</React.Suspense>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
<Route component={NotFound} />
|
||||
</Switch>{" "}
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
);
|
||||
|
||||
+23
-5
@@ -4,12 +4,25 @@ import { Switch } from "react-router-dom";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import FullscreenLoading from "components/FullscreenLoading";
|
||||
import Route from "components/ProfiledRoute";
|
||||
import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
|
||||
const Authenticated = React.lazy(() => import("components/Authenticated"));
|
||||
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
|
||||
const KeyedDocument = React.lazy(() => import("scenes/Document/KeyedDocument"));
|
||||
const Login = React.lazy(() => import("scenes/Login"));
|
||||
const Logout = React.lazy(() => import("scenes/Logout"));
|
||||
const Authenticated = React.lazy(() =>
|
||||
import(/* webpackChunkName: "authenticated" */ "components/Authenticated")
|
||||
);
|
||||
const AuthenticatedRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
);
|
||||
const Login = React.lazy(() =>
|
||||
import(/* webpackChunkName: "login" */ "scenes/Login")
|
||||
);
|
||||
const Logout = React.lazy(() =>
|
||||
import(/* webpackChunkName: "logout" */ "scenes/Logout")
|
||||
);
|
||||
|
||||
export default function Routes() {
|
||||
return (
|
||||
@@ -25,6 +38,11 @@ export default function Routes() {
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/share/:shareId/doc/${slug}`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Authenticated>
|
||||
<AuthenticatedRoutes />
|
||||
</Authenticated>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch } from "react-router-dom";
|
||||
import { Switch, Redirect } from "react-router-dom";
|
||||
import Details from "scenes/Settings/Details";
|
||||
import Groups from "scenes/Settings/Groups";
|
||||
import ImportExport from "scenes/Settings/ImportExport";
|
||||
@@ -20,8 +20,7 @@ export default function SettingsRoutes() {
|
||||
<Route exact path="/settings" component={Profile} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/people" component={People} />
|
||||
<Route exact path="/settings/people/:filter" component={People} />
|
||||
<Route exact path="/settings/members" component={People} />
|
||||
<Route exact path="/settings/groups" component={Groups} />
|
||||
<Route exact path="/settings/shares" component={Shares} />
|
||||
<Route exact path="/settings/tokens" component={Tokens} />
|
||||
@@ -29,6 +28,7 @@ export default function SettingsRoutes() {
|
||||
<Route exact path="/settings/integrations/slack" component={Slack} />
|
||||
<Route exact path="/settings/integrations/zapier" component={Zapier} />
|
||||
<Route exact path="/settings/import-export" component={ImportExport} />
|
||||
<Redirect from="/settings/people" to="/settings/members" />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
|
||||
function APITokenNew({ onSubmit }: Props) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { apiKeys, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await apiKeys.create({ name });
|
||||
ui.showToast(t("API token created", { type: "success" }));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [t, ui, name, onSubmit, apiKeys]);
|
||||
|
||||
const handleNameChange = React.useCallback((event) => {
|
||||
setName(event.target.value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Name your token something that will help you to remember it's use in
|
||||
the future, for example "local development", "production", or
|
||||
"continuous integration".
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default APITokenNew;
|
||||
+65
-42
@@ -4,7 +4,15 @@ import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
|
||||
import {
|
||||
useParams,
|
||||
Redirect,
|
||||
Link,
|
||||
Switch,
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
} from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import Search from "scenes/Search";
|
||||
@@ -29,15 +37,18 @@ import Subheading from "components/Subheading";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import Collection from "../models/Collection";
|
||||
import { updateCollectionUrl } from "../utils/routeHelpers";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useImportDocument from "hooks/useImportDocument";
|
||||
import useStores from "hooks/useStores";
|
||||
import useUnmount from "hooks/useUnmount";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
function CollectionScene() {
|
||||
const params = useParams();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies, collections, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
@@ -45,11 +56,21 @@ function CollectionScene() {
|
||||
const [error, setError] = React.useState();
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
|
||||
|
||||
const collectionId = params.id || "";
|
||||
const collection = collections.get(collectionId);
|
||||
const can = policies.abilities(collectionId || "");
|
||||
const id = params.id || "";
|
||||
const collection: ?Collection =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = policies.abilities(collection?.id || "");
|
||||
const canUser = policies.abilities(team.id);
|
||||
const { handleFiles, isImporting } = useImportDocument(collectionId);
|
||||
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
const canonicalUrl = updateCollectionUrl(match.url, collection);
|
||||
if (match.url !== canonicalUrl) {
|
||||
history.replace(canonicalUrl);
|
||||
}
|
||||
}
|
||||
}, [collection, history, id, match.url]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
@@ -59,8 +80,10 @@ function CollectionScene() {
|
||||
|
||||
React.useEffect(() => {
|
||||
setError(null);
|
||||
documents.fetchPinned({ collectionId });
|
||||
}, [documents, collectionId]);
|
||||
if (collection) {
|
||||
documents.fetchPinned({ collectionId: collection.id });
|
||||
}
|
||||
}, [documents, collection]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
@@ -68,7 +91,7 @@ function CollectionScene() {
|
||||
try {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
await collections.fetch(collectionId);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
@@ -77,9 +100,7 @@ function CollectionScene() {
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [collections, isFetching, collection, error, collectionId, can]);
|
||||
|
||||
useUnmount(ui.clearActiveCollection);
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
|
||||
const handlePermissionsModalOpen = React.useCallback(() => {
|
||||
setPermissionsModalOpen(true);
|
||||
@@ -124,29 +145,31 @@ function CollectionScene() {
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
collectionId={collectionId}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
</Action>
|
||||
{can.update && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<Separator />
|
||||
<Action>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
@@ -257,27 +280,27 @@ function CollectionScene() {
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
<Tab to={collectionUrl(collection.url)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab
|
||||
to={collectionUrl(collection.id, "alphabetical")}
|
||||
to={collectionUrl(collection.url, "alphabetical")}
|
||||
exact
|
||||
>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<Route path={collectionUrl(collection.url, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
@@ -288,7 +311,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<Route path={collectionUrl(collection.url, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
@@ -299,12 +322,12 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Route path={collectionUrl(collection.url, "recent")}>
|
||||
<Redirect
|
||||
to={collectionUrl(collection.id, "published")}
|
||||
to={collectionUrl(collection.url, "published")}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<Route path={collectionUrl(collection.url, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
@@ -316,7 +339,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<Route path={collectionUrl(collection.url, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
@@ -327,7 +350,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<Route path={collectionUrl(collection.url)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import useWindowScrollPosition from "@rehooks/window-scroll-position";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import HelpText from "components/HelpText";
|
||||
import useWindowScrollPosition from "hooks/useWindowScrollPosition";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
@@ -22,11 +22,10 @@ import DocumentComponent from "./Document";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState } from "types";
|
||||
import { type LocationWithState, type NavigationNode } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
type Props = {|
|
||||
match: Match,
|
||||
location: LocationWithState,
|
||||
@@ -39,8 +38,11 @@ type Props = {|
|
||||
history: RouterHistory,
|
||||
|};
|
||||
|
||||
const sharedTreeCache = {};
|
||||
|
||||
@observer
|
||||
class DataLoader extends React.Component<Props> {
|
||||
sharedTree: ?NavigationNode;
|
||||
@observable document: ?Document;
|
||||
@observable revision: ?Revision;
|
||||
@observable error: ?Error;
|
||||
@@ -48,6 +50,9 @@ class DataLoader extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
const { documents, match } = this.props;
|
||||
this.document = documents.getByUrl(match.params.documentSlug);
|
||||
this.sharedTree = this.document
|
||||
? sharedTreeCache[this.document.id]
|
||||
: undefined;
|
||||
this.loadDocument();
|
||||
}
|
||||
|
||||
@@ -89,8 +94,11 @@ class DataLoader extends React.Component<Props> {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
try {
|
||||
const document = await this.props.documents.fetch(slug);
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
const {
|
||||
document,
|
||||
}: { document: Document } = await this.props.documents.fetch(slug);
|
||||
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
return [
|
||||
@@ -113,7 +121,7 @@ class DataLoader extends React.Component<Props> {
|
||||
|
||||
return sortBy(
|
||||
results.map((document) => {
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
return {
|
||||
@@ -159,10 +167,14 @@ class DataLoader extends React.Component<Props> {
|
||||
}
|
||||
|
||||
try {
|
||||
this.document = await this.props.documents.fetch(documentSlug, {
|
||||
const response = await this.props.documents.fetch(documentSlug, {
|
||||
shareId,
|
||||
});
|
||||
|
||||
this.sharedTree = response.sharedTree;
|
||||
this.document = response.document;
|
||||
sharedTreeCache[this.document.id] = response.sharedTree;
|
||||
|
||||
if (revisionId && revisionId !== "latest") {
|
||||
await this.loadRevision();
|
||||
} else {
|
||||
@@ -202,10 +214,7 @@ class DataLoader extends React.Component<Props> {
|
||||
const isMove = this.props.location.pathname.match(/move$/);
|
||||
const canRedirect = !revisionId && !isMove && !shareId;
|
||||
if (canRedirect) {
|
||||
const canonicalUrl = updateDocumentUrl(
|
||||
this.props.match.url,
|
||||
document.url
|
||||
);
|
||||
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
|
||||
if (this.props.location.pathname !== canonicalUrl) {
|
||||
this.props.history.replace(canonicalUrl);
|
||||
}
|
||||
@@ -249,6 +258,7 @@ class DataLoader extends React.Component<Props> {
|
||||
readOnly={!this.isEditing || !abilities.update || document.isArchived}
|
||||
onSearchLink={this.onSearchLink}
|
||||
onCreateLink={this.onCreateLink}
|
||||
sharedTree={this.sharedTree}
|
||||
/>
|
||||
</SocketPresence>
|
||||
);
|
||||
|
||||
@@ -29,13 +29,13 @@ import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
import References from "./References";
|
||||
import { type LocationWithState, type Theme } from "types";
|
||||
import { type LocationWithState, type NavigationNode, type Theme } from "types";
|
||||
import { isCustomDomain } from "utils/domains";
|
||||
import { emojiToUrl } from "utils/emoji";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
collectionUrl,
|
||||
documentMoveUrl,
|
||||
documentHistoryUrl,
|
||||
editDocumentUrl,
|
||||
@@ -57,6 +57,7 @@ type Props = {
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
location: LocationWithState,
|
||||
sharedTree: ?NavigationNode,
|
||||
abilities: Object,
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
@@ -172,7 +173,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.onSave({ publish: true, done: true });
|
||||
}
|
||||
|
||||
@keydown(`${meta}+ctrl+h`)
|
||||
@keydown("ctrl+alt+h")
|
||||
onToggleTableOfContents(ev) {
|
||||
if (!this.props.readOnly) return;
|
||||
|
||||
@@ -289,15 +290,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
};
|
||||
|
||||
goBack = () => {
|
||||
let url;
|
||||
if (this.props.document.url) {
|
||||
url = this.props.document.url;
|
||||
} else if (this.props.match.params.id) {
|
||||
url = collectionUrl(this.props.match.params.id);
|
||||
}
|
||||
if (url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -311,7 +304,8 @@ class DocumentScene extends React.Component<Props> {
|
||||
match,
|
||||
} = this.props;
|
||||
const team = auth.team;
|
||||
const isShare = !!match.params.shareId;
|
||||
const { shareId } = match.params;
|
||||
const isShare = !!shareId;
|
||||
|
||||
const value = revision ? revision.text : document.text;
|
||||
const injectTemplate = document.injectTemplate;
|
||||
@@ -367,7 +361,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
<Header
|
||||
document={document}
|
||||
isShare={isShare}
|
||||
shareId={shareId}
|
||||
isRevision={!!revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly}
|
||||
@@ -377,6 +371,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
document.isSaving || this.isPublishing || this.isEmpty
|
||||
}
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
sharedTree={this.props.sharedTree}
|
||||
goBack={this.goBack}
|
||||
onSave={this.onSave}
|
||||
/>
|
||||
@@ -420,7 +415,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Editor
|
||||
id={document.id}
|
||||
innerRef={this.editor}
|
||||
isShare={isShare}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
key={[injectTemplate, disableEmbeds].join("-")}
|
||||
@@ -442,6 +437,15 @@ class DocumentScene extends React.Component<Props> {
|
||||
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
||||
ui={this.props.ui}
|
||||
>
|
||||
{shareId && (
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
<PublicReferences
|
||||
shareId={shareId}
|
||||
documentId={document.id}
|
||||
sharedTree={this.props.sharedTree}
|
||||
/>
|
||||
</ReferencesWrapper>
|
||||
)}
|
||||
{!isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import Textarea from "react-autosize-textarea";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_TITLE_LENGTH } from "shared/constants";
|
||||
import { light } from "shared/styles/theme";
|
||||
import parseTitle from "shared/utils/parseTitle";
|
||||
import Document from "models/Document";
|
||||
import ClickablePadding from "components/ClickablePadding";
|
||||
@@ -23,7 +24,7 @@ type Props = {|
|
||||
title: string,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
isShare: boolean,
|
||||
shareId: ?string,
|
||||
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
innerRef: { current: any },
|
||||
children: React.Node,
|
||||
@@ -32,6 +33,7 @@ type Props = {|
|
||||
@observer
|
||||
class DocumentEditor extends React.Component<Props> {
|
||||
@observable activeLinkEvent: ?MouseEvent;
|
||||
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
|
||||
|
||||
focusAtStart = () => {
|
||||
if (this.props.innerRef.current) {
|
||||
@@ -96,7 +98,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
title,
|
||||
onChangeTitle,
|
||||
isDraft,
|
||||
isShare,
|
||||
shareId,
|
||||
readOnly,
|
||||
innerRef,
|
||||
children,
|
||||
@@ -113,15 +115,18 @@ class DocumentEditor extends React.Component<Props> {
|
||||
{readOnly ? (
|
||||
<Title
|
||||
as="div"
|
||||
ref={this.ref}
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
$isStarred={document.isStarred}
|
||||
dir="auto"
|
||||
>
|
||||
<span>{normalizedTitle}</span>{" "}
|
||||
{!isShare && <StarButton document={document} size={32} />}
|
||||
{!shareId && <StarButton document={document} size={32} />}
|
||||
</Title>
|
||||
) : (
|
||||
<Title
|
||||
type="text"
|
||||
ref={this.ref}
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={document.placeholder}
|
||||
@@ -129,13 +134,21 @@ class DocumentEditor extends React.Component<Props> {
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
autoFocus={!title}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
dir="auto"
|
||||
/>
|
||||
)}
|
||||
{!shareId && (
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
rtl={
|
||||
this.ref.current
|
||||
? window.getComputedStyle(this.ref.current).direction === "rtl"
|
||||
: false
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
/>
|
||||
<Editor
|
||||
ref={innerRef}
|
||||
autoFocus={!!title && !this.props.defaultValue}
|
||||
@@ -143,11 +156,12 @@ class DocumentEditor extends React.Component<Props> {
|
||||
onHoverLink={this.handleLinkActive}
|
||||
scrollTo={window.location.hash}
|
||||
readOnly={readOnly}
|
||||
shareId={shareId}
|
||||
grow
|
||||
{...rest}
|
||||
/>
|
||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
||||
{this.activeLinkEvent && !isShare && readOnly && (
|
||||
{this.activeLinkEvent && !shareId && readOnly && (
|
||||
<HoverPreview
|
||||
node={this.activeLinkEvent.target}
|
||||
event={this.activeLinkEvent}
|
||||
@@ -202,6 +216,12 @@ const Title = styled(Textarea)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
color: ${(props) => light.text};
|
||||
-webkit-text-fill-color: ${(props) => light.text};
|
||||
background: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentEditor;
|
||||
|
||||
@@ -13,24 +13,26 @@ import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
import Badge from "components/Badge";
|
||||
import Breadcrumb, { Slash } from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
import Collaborators from "components/Collaborators";
|
||||
import Fade from "components/Fade";
|
||||
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
||||
import Header from "components/Header";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
import ShareButton from "./ShareButton";
|
||||
import useMobile from "hooks/useMobile";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
||||
import TemplatesMenu from "menus/TemplatesMenu";
|
||||
import { type NavigationNode } from "types";
|
||||
import { metaDisplay } from "utils/keyboard";
|
||||
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
isShare: boolean,
|
||||
sharedTree: ?NavigationNode,
|
||||
shareId: ?string,
|
||||
isDraft: boolean,
|
||||
isEditing: boolean,
|
||||
isRevision: boolean,
|
||||
@@ -48,7 +50,7 @@ type Props = {|
|
||||
|
||||
function DocumentHeader({
|
||||
document,
|
||||
isShare,
|
||||
shareId,
|
||||
isEditing,
|
||||
isDraft,
|
||||
isPublishing,
|
||||
@@ -56,6 +58,7 @@ function DocumentHeader({
|
||||
isSaving,
|
||||
savingIsDisabled,
|
||||
publishingIsDisabled,
|
||||
sharedTree,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -80,7 +83,7 @@ function DocumentHeader({
|
||||
const toc = (
|
||||
<Tooltip
|
||||
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
shortcut="ctrl+alt+h"
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
@@ -116,11 +119,19 @@ function DocumentHeader({
|
||||
</Action>
|
||||
);
|
||||
|
||||
if (isShare) {
|
||||
if (shareId) {
|
||||
return (
|
||||
<Header
|
||||
title={document.title}
|
||||
breadcrumb={toc}
|
||||
breadcrumb={
|
||||
<PublicBreadcrumb
|
||||
documentId={document.id}
|
||||
shareId={shareId}
|
||||
sharedTree={sharedTree}
|
||||
>
|
||||
{toc}
|
||||
</PublicBreadcrumb>
|
||||
}
|
||||
actions={canEdit ? editAction : <div />}
|
||||
/>
|
||||
);
|
||||
@@ -130,14 +141,9 @@ function DocumentHeader({
|
||||
<>
|
||||
<Header
|
||||
breadcrumb={
|
||||
<Breadcrumb document={document}>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Slash />
|
||||
{toc}
|
||||
</>
|
||||
)}
|
||||
</Breadcrumb>
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{!isEditing && toc}
|
||||
</DocumentBreadcrumb>
|
||||
}
|
||||
title={
|
||||
<>
|
||||
@@ -148,12 +154,10 @@ function DocumentHeader({
|
||||
actions={
|
||||
<>
|
||||
{!isPublishing && isSaving && <Status>{t("Saving")}…</Status>}
|
||||
<Fade>
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
</Fade>
|
||||
<Collaborators
|
||||
document={document}
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
{isEditing && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import type { NavigationNode } from "types";
|
||||
|
||||
type Props = {|
|
||||
documentId: string,
|
||||
shareId: string,
|
||||
sharedTree: ?NavigationNode,
|
||||
children?: React.Node,
|
||||
|};
|
||||
|
||||
function pathToDocument(sharedTree, documentId) {
|
||||
let path = [];
|
||||
const traveler = (nodes, previousPath) => {
|
||||
nodes.forEach((childNode) => {
|
||||
const newPath = [...previousPath, childNode];
|
||||
if (childNode.id === documentId) {
|
||||
path = newPath;
|
||||
return;
|
||||
}
|
||||
return traveler(childNode.children, newPath);
|
||||
});
|
||||
};
|
||||
|
||||
if (sharedTree) {
|
||||
traveler([sharedTree], []);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
const PublicBreadcrumb = ({
|
||||
documentId,
|
||||
shareId,
|
||||
sharedTree,
|
||||
children,
|
||||
}: Props) => {
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
pathToDocument(sharedTree, documentId)
|
||||
.slice(0, -1)
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
to: `/share/${shareId}${item.url}`,
|
||||
};
|
||||
}),
|
||||
[sharedTree, shareId, documentId]
|
||||
);
|
||||
|
||||
return <Breadcrumb items={items} children={children} />;
|
||||
};
|
||||
|
||||
export default PublicBreadcrumb;
|
||||
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Subheading from "components/Subheading";
|
||||
import ReferenceListItem from "./ReferenceListItem";
|
||||
import { type NavigationNode } from "types";
|
||||
|
||||
type Props = {|
|
||||
shareId: string,
|
||||
documentId: string,
|
||||
sharedTree: NavigationNode,
|
||||
|};
|
||||
|
||||
function PublicReferences(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { shareId, documentId, sharedTree } = props;
|
||||
|
||||
// The sharedTree is the entire document tree starting at the shared document
|
||||
// we must filter down the tree to only the part with the document we're
|
||||
// currently viewing
|
||||
const children = React.useMemo(() => {
|
||||
let result;
|
||||
|
||||
function findChildren(node) {
|
||||
if (!node) return;
|
||||
if (node.id === documentId) {
|
||||
result = node.children;
|
||||
} else {
|
||||
node.children.forEach((node) => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
findChildren(node);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return findChildren(sharedTree) || [];
|
||||
}, [documentId, sharedTree]);
|
||||
|
||||
if (!children.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Subheading>{t("Nested documents")}</Subheading>
|
||||
{children.map((node) => (
|
||||
<ReferenceListItem key={node.id} document={node} shareId={shareId} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PublicReferences);
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -8,6 +9,7 @@ import DocumentMeta from "components/DocumentMeta";
|
||||
import type { NavigationNode } from "types";
|
||||
|
||||
type Props = {|
|
||||
shareId?: string,
|
||||
document: Document | NavigationNode,
|
||||
anchor?: string,
|
||||
showCollection?: boolean,
|
||||
@@ -31,7 +33,8 @@ const DocumentLink = styled(Link)`
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
max-width: 90%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
@@ -43,27 +46,52 @@ const Title = styled.h3`
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
@observer
|
||||
class ReferenceListItem extends React.Component<Props> {
|
||||
render() {
|
||||
const { document, showCollection, anchor, ...rest } = this.props;
|
||||
const StyledDocumentIcon = styled(DocumentIcon)`
|
||||
margin-left: -4px;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
hash: anchor ? `d-${anchor}` : undefined,
|
||||
state: { title: document.title },
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Title>{document.title}</Title>
|
||||
{document.updatedBy && (
|
||||
<DocumentMeta document={document} showCollection={showCollection} />
|
||||
)}
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
const Emoji = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: -4px;
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
function ReferenceListItem({
|
||||
document,
|
||||
showCollection,
|
||||
anchor,
|
||||
shareId,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
|
||||
hash: anchor ? `d-${anchor}` : undefined,
|
||||
state: { title: document.title },
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Title dir="auto">
|
||||
{document.emoji ? (
|
||||
<Emoji>{document.emoji}</Emoji>
|
||||
) : (
|
||||
<StyledDocumentIcon color="currentColor" />
|
||||
)}{" "}
|
||||
{document.emoji
|
||||
? document.title.replace(new RegExp(`^${document.emoji}`), "")
|
||||
: document.title}
|
||||
</Title>
|
||||
{document.updatedBy && (
|
||||
<DocumentMeta document={document} showCollection={showCollection} />
|
||||
)}
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReferenceListItem;
|
||||
export default observer(ReferenceListItem);
|
||||
|
||||
@@ -3,11 +3,10 @@ import { observer } from "mobx-react";
|
||||
import { GlobeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { usePopoverState, Popover, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import Popover from "components/Popover";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import SharePopover from "./SharePopover";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -20,7 +19,9 @@ function ShareButton({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { shares } = useStores();
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const isPubliclyShared = share && share.published;
|
||||
const sharedParent = shares.getByDocumentParents(document.id);
|
||||
const isPubliclyShared =
|
||||
(share && share.published) || (sharedParent && sharedParent.published);
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
@@ -55,28 +56,15 @@ function ShareButton({ document }: Props) {
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} aria-label={t("Share")}>
|
||||
<Contents>
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
onSubmit={popover.hide}
|
||||
/>
|
||||
</Contents>
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
onSubmit={popover.hide}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Contents = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 24px 24px 12px;
|
||||
width: 380px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`;
|
||||
|
||||
export default observer(ShareButton);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobeIcon, PadlockIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Share from "models/Share";
|
||||
@@ -13,23 +13,25 @@ import CopyToClipboard from "components/CopyToClipboard";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Notice from "components/Notice";
|
||||
import Switch from "components/Switch";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
share: Share,
|
||||
sharedParent: ?Share,
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
|
||||
function DocumentShare({ document, share, onSubmit }: Props) {
|
||||
function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { policies, shares, ui } = useStores();
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const timeout = React.useRef<?TimeoutID>();
|
||||
const can = policies.abilities(share ? share.id : "");
|
||||
const canPublish = can.update && !document.isTemplate;
|
||||
const isPubliclyShared = (share && share.published) || sharedParent;
|
||||
|
||||
React.useEffect(() => {
|
||||
document.share();
|
||||
@@ -41,14 +43,26 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
invariant(share, "Share must exist");
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await share.save({ published: event.currentTarget.checked });
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[document.id, shares, ui]
|
||||
);
|
||||
|
||||
const handleChildDocumentsChange = React.useCallback(
|
||||
async (event) => {
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
invariant(share, "Share must exist");
|
||||
|
||||
try {
|
||||
await share.save({
|
||||
includeChildDocuments: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
[document.id, shares, ui]
|
||||
@@ -68,7 +82,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Heading>
|
||||
{share && share.published ? (
|
||||
{isPubliclyShared ? (
|
||||
<GlobeIcon size={28} color="currentColor" />
|
||||
) : (
|
||||
<PadlockIcon size={28} color="currentColor" />
|
||||
@@ -76,40 +90,71 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
||||
{t("Share this document")}
|
||||
</Heading>
|
||||
|
||||
{sharedParent && (
|
||||
<Notice>
|
||||
<Trans
|
||||
defaults="This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared"
|
||||
values={{ documentTitle: sharedParent.documentTitle }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{canPublish && (
|
||||
<PrivacySwitch>
|
||||
<SwitchWrapper>
|
||||
<Switch
|
||||
id="published"
|
||||
label={t("Publish to internet")}
|
||||
onChange={handlePublishedChange}
|
||||
checked={share ? share.published : false}
|
||||
disabled={!share || isSaving}
|
||||
disabled={!share}
|
||||
/>
|
||||
<Privacy>
|
||||
<PrivacyText>
|
||||
<SwitchLabel>
|
||||
<SwitchText>
|
||||
{share.published
|
||||
? t("Anyone with the link can view this document")
|
||||
: t("Only team members with access can view")}
|
||||
: t("Only team members with permission can view")}
|
||||
{share.lastAccessedAt && (
|
||||
<>
|
||||
.{" "}
|
||||
{t("The shared link was last accessed {{ timeAgo }}.", {
|
||||
timeAgo: distanceInWordsToNow(share.lastAccessedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timeAgo: formatDistanceToNow(
|
||||
Date.parse(share.lastAccessedAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
}
|
||||
),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</PrivacyText>
|
||||
</Privacy>
|
||||
</PrivacySwitch>
|
||||
</SwitchText>
|
||||
</SwitchLabel>
|
||||
</SwitchWrapper>
|
||||
)}
|
||||
{share && share.published && (
|
||||
<SwitchWrapper>
|
||||
<Switch
|
||||
id="includeChildDocuments"
|
||||
label={t("Share nested documents")}
|
||||
onChange={handleChildDocumentsChange}
|
||||
checked={share ? share.includeChildDocuments : false}
|
||||
disabled={!share}
|
||||
/>
|
||||
<SwitchLabel>
|
||||
<SwitchText>
|
||||
{share.includeChildDocuments
|
||||
? t("Nested documents are publicly available")
|
||||
: t("Nested documents are not shared")}
|
||||
</SwitchText>
|
||||
</SwitchLabel>
|
||||
</SwitchWrapper>
|
||||
)}
|
||||
<Flex>
|
||||
<InputLink
|
||||
type="text"
|
||||
label={t("Link")}
|
||||
placeholder={`${t("Loading")}…`}
|
||||
value={share ? share.url : undefined}
|
||||
value={share ? share.url : ""}
|
||||
labelHidden
|
||||
readOnly
|
||||
/>
|
||||
@@ -126,11 +171,11 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
||||
const Heading = styled.h2`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
margin-top: 12px;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
const PrivacySwitch = styled.div`
|
||||
const SwitchWrapper = styled.div`
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
@@ -139,7 +184,7 @@ const InputLink = styled(Input)`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const Privacy = styled(Flex)`
|
||||
const SwitchLabel = styled(Flex)`
|
||||
flex-align: center;
|
||||
|
||||
svg {
|
||||
@@ -147,9 +192,9 @@ const Privacy = styled(Flex)`
|
||||
}
|
||||
`;
|
||||
|
||||
const PrivacyText = styled(HelpText)`
|
||||
const SwitchText = styled(HelpText)`
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default observer(DocumentShare);
|
||||
export default observer(SharePopover);
|
||||
|
||||
@@ -17,12 +17,13 @@ type Props = {
|
||||
|
||||
function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, documents } = useStores();
|
||||
const { ui, documents, collections } = useStores();
|
||||
const history = useHistory();
|
||||
const [isDeleting, setDeleting] = React.useState(false);
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
const { showToast } = ui;
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
@@ -45,7 +46,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
// otherwise, redirect to the collection home
|
||||
history.push(collectionUrl(document.collectionId));
|
||||
history.push(collectionUrl(collection?.url || "/"));
|
||||
}
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
@@ -54,7 +55,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
[showToast, onSubmit, ui, document, documents, history]
|
||||
[showToast, onSubmit, ui, document, documents, history, collection]
|
||||
);
|
||||
|
||||
const handleArchive = React.useCallback(
|
||||
|
||||
+43
-44
@@ -1,58 +1,57 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type RouterHistory,
|
||||
type Location,
|
||||
type Match,
|
||||
} from "react-router-dom";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import useStores from "hooks/useStores";
|
||||
import { editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
location: Location,
|
||||
documents: DocumentsStore,
|
||||
ui: UiStore,
|
||||
match: Match,
|
||||
};
|
||||
function DocumentNew() {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const { t } = useTranslation();
|
||||
const { documents, ui, collections } = useStores();
|
||||
const id = match.params.id || "";
|
||||
|
||||
class DocumentNew extends React.Component<Props> {
|
||||
async componentDidMount() {
|
||||
const params = queryString.parse(this.props.location.search);
|
||||
useEffect(() => {
|
||||
async function createDocument() {
|
||||
const params = queryString.parse(location.search);
|
||||
try {
|
||||
const collection = await collections.fetch(id);
|
||||
|
||||
try {
|
||||
const document = await this.props.documents.create({
|
||||
collectionId: this.props.match.params.id,
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
this.props.history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Couldn’t create the document, try again?", {
|
||||
type: "error",
|
||||
});
|
||||
this.props.history.goBack();
|
||||
const document = await documents.create({
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
|
||||
history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
ui.showToast(t("Couldn’t create the document, try again?"), {
|
||||
type: "error",
|
||||
});
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
createDocument();
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("documents", "ui")(DocumentNew);
|
||||
export default observer(DocumentNew);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Document from "models/Document.js";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
|
||||
function DocumentPermanentDelete({ document, onSubmit }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { ui, documents } = useStores();
|
||||
const { showToast } = ui;
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await documents.delete(document, { permanent: true });
|
||||
showToast(t("Document permanently deleted"), { type: "success" });
|
||||
onSubmit();
|
||||
history.push("/trash");
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[document, onSubmit, showToast, t, history, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone."
|
||||
values={{ documentTitle: document.titleWithDefault }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentPermanentDelete);
|
||||
@@ -50,10 +50,13 @@ class Drafts extends React.Component<Props> {
|
||||
}) => {
|
||||
this.props.history.replace({
|
||||
pathname: this.props.location.pathname,
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
search: queryString.stringify(
|
||||
{
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function KeyboardShortcuts() {
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key>Ctrl</Key> + <Key>{metaDisplay}</Key> + <Key>h</Key>
|
||||
<Key>Ctrl</Key> + <Key>Alt</Key> + <Key>h</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Table of contents"),
|
||||
@@ -413,8 +413,8 @@ const List = styled.dl`
|
||||
const Keys = styled.dt`
|
||||
float: right;
|
||||
width: 45%;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
margin: 0 0 10px;
|
||||
clear: left;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
@@ -426,8 +426,7 @@ const Keys = styled.dt`
|
||||
const Label = styled.dd`
|
||||
float: left;
|
||||
width: 55%;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
margin: 0 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { observer, inject } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, Link } from "react-router-dom";
|
||||
@@ -103,8 +102,9 @@ class Search extends React.Component<Props> {
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
if (this.firstDocument) {
|
||||
const element = ReactDOM.findDOMNode(this.firstDocument);
|
||||
if (element instanceof HTMLElement) element.focus();
|
||||
if (this.firstDocument instanceof HTMLElement) {
|
||||
this.firstDocument.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -140,10 +140,13 @@ class Search extends React.Component<Props> {
|
||||
}) => {
|
||||
this.props.history.replace({
|
||||
pathname: this.props.location.pathname,
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
search: queryString.stringify(
|
||||
{
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
import FilterOptions from "components/FilterOptions";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
import FilterOptions from "components/FilterOptions";
|
||||
|
||||
type Props = {|
|
||||
dateFilter: ?string,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// @flow
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
|
||||
type Props = {|
|
||||
label: string,
|
||||
note?: string,
|
||||
onSelect: () => void,
|
||||
active: boolean,
|
||||
|};
|
||||
|
||||
const FilterOption = ({ label, note, onSelect, active, ...rest }: Props) => {
|
||||
return (
|
||||
<MenuItem onClick={active ? undefined : onSelect} {...rest}>
|
||||
{(props) => (
|
||||
<ListItem>
|
||||
<Button active={active} {...props}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<span>
|
||||
{label}
|
||||
{note && <Description small>{note}</Description>}
|
||||
</span>
|
||||
{active && <Checkmark />}
|
||||
</Flex>
|
||||
</Button>
|
||||
</ListItem>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Description = styled(HelpText)`
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.2em;
|
||||
`;
|
||||
|
||||
const Checkmark = styled(CheckmarkIcon)`
|
||||
flex-shrink: 0;
|
||||
padding-left: 4px;
|
||||
fill: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 16px;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
text-align: left;
|
||||
font-weight: ${(props) => (props.active ? "600" : "normal")};
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
|
||||
${HelpText} {
|
||||
font-weight: normal;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 8px;
|
||||
font-size: 15px;
|
||||
min-height: 32px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const ListItem = styled("li")`
|
||||
list-style: none;
|
||||
max-width: 250px;
|
||||
`;
|
||||
|
||||
export default FilterOption;
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
import FilterOptions from "components/FilterOptions";
|
||||
|
||||
type Props = {|
|
||||
includeArchived?: boolean,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "./FilterOptions";
|
||||
import FilterOptions from "components/FilterOptions";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import GroupNew from "scenes/GroupNew";
|
||||
import { Action } from "components/Actions";
|
||||
import Button from "components/Button";
|
||||
import Empty from "components/Empty";
|
||||
import GroupListItem from "components/GroupListItem";
|
||||
@@ -33,25 +34,31 @@ function Groups() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Scene title={t("Groups")} icon={<GroupIcon color="currentColor" />}>
|
||||
<Scene
|
||||
title={t("Groups")}
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.createGroup && (
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewGroupModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("New group")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("Groups")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Groups can be used to organize and manage the people on your team.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
|
||||
{can.createGroup && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewGroupModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{`${t("New group")}…`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Subheading>{t("All groups")}</Subheading>
|
||||
<PaginatedList
|
||||
items={groups.orderedData}
|
||||
|
||||
+218
-133
@@ -1,161 +1,246 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction, Trans } from "react-i18next";
|
||||
import { type Match } from "react-router-dom";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import { PAGINATION_SYMBOL } from "stores/BaseStore";
|
||||
import Invite from "scenes/Invite";
|
||||
import Bubble from "components/Bubble";
|
||||
import { Action } from "components/Actions";
|
||||
import Button from "components/Button";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import Scene from "components/Scene";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs, { Separator } from "components/Tabs";
|
||||
import UserListItem from "./components/UserListItem";
|
||||
import PeopleTable from "./components/PeopleTable";
|
||||
import UserStatusFilter from "./components/UserStatusFilter";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useQuery from "hooks/useQuery";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
users: UsersStore,
|
||||
policies: PoliciesStore,
|
||||
match: Match,
|
||||
t: TFunction,
|
||||
};
|
||||
function People(props) {
|
||||
const topRef = React.useRef();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const team = useCurrentTeam();
|
||||
const { users, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const params = useQuery();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
const [userIds, setUserIds] = React.useState([]);
|
||||
const can = policies.abilities(team.id);
|
||||
const query = params.get("query") || "";
|
||||
const filter = params.get("filter") || "";
|
||||
const sort = params.get("sort") || "name";
|
||||
const direction = (params.get("direction") || "asc").toUpperCase();
|
||||
const page = parseInt(params.get("page") || 0, 10);
|
||||
const limit = 25;
|
||||
|
||||
@observer
|
||||
class People extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
React.useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
componentDidMount() {
|
||||
const { team } = this.props.auth;
|
||||
if (team) {
|
||||
this.props.users.fetchCounts(team.id);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await users.fetchPage({
|
||||
offset: page * limit,
|
||||
limit,
|
||||
sort,
|
||||
direction,
|
||||
query,
|
||||
filter,
|
||||
});
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
setUserIds(response.map((u) => u.id));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
fetchData();
|
||||
}, [query, sort, filter, page, direction, users]);
|
||||
|
||||
fetchPage = (params) => {
|
||||
return this.props.users.fetchPage({ ...params, includeSuspended: true });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, policies, match, t } = this.props;
|
||||
const { filter } = match.params;
|
||||
const currentUser = auth.user;
|
||||
const team = auth.team;
|
||||
invariant(currentUser, "User should exist");
|
||||
invariant(team, "Team should exist");
|
||||
|
||||
let users = this.props.users.active;
|
||||
if (filter === "all") {
|
||||
users = this.props.users.all;
|
||||
React.useEffect(() => {
|
||||
let filtered = users.orderedData;
|
||||
if (!filter) {
|
||||
filtered = users.active.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "all") {
|
||||
filtered = users.orderedData.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "admins") {
|
||||
users = this.props.users.admins;
|
||||
filtered = users.admins.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "suspended") {
|
||||
users = this.props.users.suspended;
|
||||
filtered = users.suspended.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "invited") {
|
||||
users = this.props.users.invited;
|
||||
filtered = users.invited.filter((u) => userIds.includes(u.id));
|
||||
} else if (filter === "viewers") {
|
||||
users = this.props.users.viewers;
|
||||
filtered = users.viewers.filter((u) => userIds.includes(u.id));
|
||||
}
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
const { counts } = this.props.users;
|
||||
// sort the resulting data by the original order from the server
|
||||
setData(sortBy(filtered, (item) => userIds.indexOf(item.id)));
|
||||
}, [
|
||||
filter,
|
||||
users.active,
|
||||
users.admins,
|
||||
users.orderedData,
|
||||
users.suspended,
|
||||
users.invited,
|
||||
users.viewers,
|
||||
userIds,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Scene title={t("People")} icon={<UserIcon color="currentColor" />}>
|
||||
<Heading>{t("People")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
{can.inviteUser && (
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Invite people")}…
|
||||
</Button>
|
||||
)}
|
||||
const handleInviteModalOpen = React.useCallback(() => {
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
<Tabs>
|
||||
<Tab to="/settings/people" exact>
|
||||
{t("Active")} <Bubble count={counts.active} />
|
||||
</Tab>
|
||||
<Tab to="/settings/people/admins" exact>
|
||||
{t("Admins")} <Bubble count={counts.admins} />
|
||||
</Tab>
|
||||
{can.update && (
|
||||
<Tab to="/settings/people/suspended" exact>
|
||||
{t("Suspended")} <Bubble count={counts.suspended} />
|
||||
</Tab>
|
||||
)}
|
||||
<Tab to="/settings/people/viewers" exact>
|
||||
{t("Viewers")} <Bubble count={counts.viewers} />
|
||||
</Tab>
|
||||
<Tab to="/settings/people/all" exact>
|
||||
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
|
||||
</Tab>
|
||||
const handleInviteModalClose = React.useCallback(() => {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(filter) => {
|
||||
if (filter) {
|
||||
params.set("filter", filter);
|
||||
params.delete("page");
|
||||
} else {
|
||||
params.delete("filter");
|
||||
}
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = React.useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
if (value) {
|
||||
params.set("query", event.target.value);
|
||||
params.delete("page");
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort, direction) => {
|
||||
if (sort) {
|
||||
params.set("sort", sort);
|
||||
} else {
|
||||
params.delete("sort");
|
||||
}
|
||||
if (direction === "DESC") {
|
||||
params.set("direction", direction.toLowerCase());
|
||||
} else {
|
||||
params.delete("direction");
|
||||
}
|
||||
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: "instant",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("Members")}
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.inviteUser && (
|
||||
<>
|
||||
<Separator />
|
||||
<Tab to="/settings/people/invited" exact>
|
||||
{t("Invited")} <Bubble count={counts.invited} />
|
||||
</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
<PaginatedList
|
||||
items={users}
|
||||
empty={<Empty>{t("No people to see here.")}</Empty>}
|
||||
fetch={this.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<UserListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
showMenu={can.update && currentUser.id !== item.id}
|
||||
/>
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("Invite people")}…
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("Members")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Flex gap={8}>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
{can.inviteUser && (
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
<UserStatusFilter activeKey={filter} onSelect={handleFilter} />
|
||||
</Flex>
|
||||
<PeopleTable
|
||||
topRef={topRef}
|
||||
data={data}
|
||||
canManage={can.manage}
|
||||
isLoading={isLoading}
|
||||
onChangeSort={handleChangeSort}
|
||||
onChangePage={handleChangePage}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
{can.inviteUser && (
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject(
|
||||
"auth",
|
||||
"users",
|
||||
"policies"
|
||||
)(withTranslation()<People>(People));
|
||||
export default observer(People);
|
||||
|
||||
@@ -1,89 +1,86 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import ApiKeysStore from "stores/ApiKeysStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import APITokenNew from "scenes/APITokenNew";
|
||||
import { Action } from "components/Actions";
|
||||
import Button from "components/Button";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import List from "components/List";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import TokenListItem from "./components/TokenListItem";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
apiKeys: ApiKeysStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
function Tokens() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys, policies } = useStores();
|
||||
const [newModalOpen, setNewModalOpen] = React.useState(false);
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
@observer
|
||||
class Tokens extends React.Component<Props> {
|
||||
@observable name: string = "";
|
||||
const handleNewModalOpen = React.useCallback(() => {
|
||||
setNewModalOpen(true);
|
||||
}, []);
|
||||
|
||||
componentDidMount() {
|
||||
this.props.apiKeys.fetchPage({ limit: 100 });
|
||||
}
|
||||
const handleNewModalClose = React.useCallback(() => {
|
||||
setNewModalOpen(false);
|
||||
}, []);
|
||||
|
||||
handleUpdate = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
};
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
try {
|
||||
ev.preventDefault();
|
||||
await this.props.apiKeys.create({ name: this.name });
|
||||
this.name = "";
|
||||
} catch (error) {
|
||||
this.props.ui.showToast(error.message, { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { apiKeys } = this.props;
|
||||
const hasApiKeys = apiKeys.orderedData.length > 0;
|
||||
|
||||
return (
|
||||
<Scene title="API Tokens" icon={<CodeIcon color="currentColor" />}>
|
||||
<Heading>API Tokens</Heading>
|
||||
<HelpText>
|
||||
You can create an unlimited amount of personal tokens to authenticate
|
||||
with the API. For more details about the API take a look at the{" "}
|
||||
<a href="https://www.getoutline.com/developers">
|
||||
developer documentation
|
||||
</a>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
{hasApiKeys && (
|
||||
<List>
|
||||
{apiKeys.orderedData.map((token) => (
|
||||
<TokenListItem
|
||||
key={token.id}
|
||||
token={token}
|
||||
onDelete={token.delete}
|
||||
return (
|
||||
<Scene
|
||||
title={t("API Tokens")}
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New token")}…`}
|
||||
onClick={handleNewModalOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API Tokens")}</Heading>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="You can create an unlimited amount of personal tokens to authenticate
|
||||
with the API. Tokens have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a href="https://www.getoutline.com/developers" target="_blank" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</HelpText>
|
||||
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<Input
|
||||
onChange={this.handleUpdate}
|
||||
placeholder="Token label (eg. development)"
|
||||
value={this.name}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
value="Create Token"
|
||||
disabled={apiKeys.isSaving}
|
||||
/>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<Subheading sticky>{t("Tokens")}</Subheading>}
|
||||
renderItem={(token) => (
|
||||
<TokenListItem key={token.id} token={token} onDelete={token.delete} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("Create a token")}
|
||||
onRequestClose={handleNewModalClose}
|
||||
isOpen={newModalOpen}
|
||||
>
|
||||
<APITokenNew onSubmit={handleNewModalClose} />
|
||||
</Modal>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("apiKeys", "ui")(Tokens);
|
||||
export default observer(Tokens);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Badge from "components/Badge";
|
||||
import Flex from "components/Flex";
|
||||
import { type Props as TableProps } from "components/Table";
|
||||
import Time from "components/Time";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import UserMenu from "menus/UserMenu";
|
||||
|
||||
const Table = React.lazy<TableProps>(() =>
|
||||
import(/* webpackChunkName: "table" */ "components/Table")
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
...$Diff<TableProps, { columns: any }>,
|
||||
data: User[],
|
||||
canManage: boolean,
|
||||
|};
|
||||
|
||||
function PeopleTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
id: "name",
|
||||
Header: t("Name"),
|
||||
accessor: "name",
|
||||
Cell: observer(({ value, row }) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar src={row.original.avatarUrl} size={32} /> {value}{" "}
|
||||
{currentUser.id === row.original.id && `(${t("You")})`}
|
||||
</Flex>
|
||||
)),
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
id: "email",
|
||||
Header: t("Email"),
|
||||
accessor: "email",
|
||||
Cell: observer(({ value }) => value),
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
id: "lastActiveAt",
|
||||
Header: t("Last active"),
|
||||
accessor: "lastActiveAt",
|
||||
Cell: observer(
|
||||
({ value }) => value && <Time dateTime={value} addSuffix />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "isAdmin",
|
||||
Header: t("Role"),
|
||||
accessor: "rank",
|
||||
Cell: observer(({ row }) => (
|
||||
<Badges>
|
||||
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
|
||||
{row.original.isAdmin && <Badge primary>{t("Admin")}</Badge>}
|
||||
{row.original.isViewer && <Badge>{t("Viewer")}</Badge>}
|
||||
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</Badges>
|
||||
)),
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
Header: " ",
|
||||
accessor: "id",
|
||||
className: "actions",
|
||||
Cell: observer(
|
||||
({ row, value }) =>
|
||||
currentUser.id !== value && <UserMenu user={row.original} />
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
].filter((i) => i),
|
||||
[t, canManage, currentUser]
|
||||
);
|
||||
|
||||
return <Table columns={columns} {...rest} />;
|
||||
}
|
||||
|
||||
const Badges = styled.div`
|
||||
margin-left: -10px;
|
||||
`;
|
||||
|
||||
export default PeopleTable;
|
||||
@@ -4,17 +4,20 @@ import ApiKey from "models/ApiKey";
|
||||
import Button from "components/Button";
|
||||
import ListItem from "components/List/Item";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
token: ApiKey,
|
||||
onDelete: (tokenId: string) => Promise<void>,
|
||||
};
|
||||
|};
|
||||
|
||||
const TokenListItem = ({ token, onDelete }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
key={token.id}
|
||||
title={token.name}
|
||||
subtitle={<code>{token.secret}</code>}
|
||||
title={
|
||||
<>
|
||||
{token.name} – <code>{token.secret}</code>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button onClick={() => onDelete(token.id)} neutral>
|
||||
Revoke
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "components/FilterOptions";
|
||||
|
||||
type Props = {|
|
||||
activeKey: string,
|
||||
onSelect: (key: ?string) => void,
|
||||
|};
|
||||
|
||||
const UserStatusFilter = ({ activeKey, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("Active"),
|
||||
},
|
||||
{
|
||||
key: "all",
|
||||
label: t("Everyone"),
|
||||
},
|
||||
{
|
||||
key: "admins",
|
||||
label: t("Admins"),
|
||||
},
|
||||
{
|
||||
key: "suspended",
|
||||
label: t("Suspended"),
|
||||
},
|
||||
{
|
||||
key: "invited",
|
||||
label: t("Invited"),
|
||||
},
|
||||
{
|
||||
key: "viewers",
|
||||
label: t("Viewers"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={activeKey}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Active")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserStatusFilter;
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -52,7 +52,7 @@ function UserProfile(props: Props) {
|
||||
? t("Joined")
|
||||
: t("Invited")}{" "}
|
||||
{t("{{ time }} ago.", {
|
||||
time: distanceInWordsToNow(new Date(user.createdAt)),
|
||||
time: formatDistanceToNow(Date.parse(user.createdAt)),
|
||||
})}
|
||||
{user.isAdmin && (
|
||||
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
|
||||
|
||||
@@ -15,6 +15,8 @@ function modelNameFromClassName(string) {
|
||||
|
||||
export const DEFAULT_PAGINATION_LIMIT = 25;
|
||||
|
||||
export const PAGINATION_SYMBOL = Symbol.for("pagination");
|
||||
|
||||
export default class BaseStore<T: BaseModel> {
|
||||
@observable data: Map<string, T> = new Map();
|
||||
@observable isFetching: boolean = false;
|
||||
@@ -137,7 +139,8 @@ export default class BaseStore<T: BaseModel> {
|
||||
throw new Error(`Cannot fetch ${this.modelName}`);
|
||||
}
|
||||
|
||||
let item = this.data.get(id);
|
||||
const item = this.data.get(id);
|
||||
|
||||
if (item && !options.force) return item;
|
||||
|
||||
this.isFetching = true;
|
||||
@@ -175,7 +178,10 @@ export default class BaseStore<T: BaseModel> {
|
||||
res.data.forEach(this.add);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
return res.data;
|
||||
|
||||
let response = res.data;
|
||||
response[PAGINATION_SYMBOL] = res.pagination;
|
||||
return response;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { concat, last } from "lodash";
|
||||
import { concat, find, last } from "lodash";
|
||||
import { computed, action } from "mobx";
|
||||
import Collection from "models/Collection";
|
||||
import BaseStore from "./BaseStore";
|
||||
@@ -126,6 +126,30 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
return result;
|
||||
}
|
||||
|
||||
@action
|
||||
async fetch(id: string, options: Object = {}): Promise<*> {
|
||||
const item = this.get(id) || this.getByUrl(id);
|
||||
|
||||
if (item && !options.force) return item;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/collections.info`, { id });
|
||||
invariant(res && res.data, "Collection not available");
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 403) {
|
||||
this.remove(id);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
getPathForDocument(documentId: string): ?DocumentPath {
|
||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||
}
|
||||
@@ -135,6 +159,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
if (path) return path.title;
|
||||
}
|
||||
|
||||
getByUrl(url: string): ?Collection {
|
||||
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
|
||||
}
|
||||
|
||||
delete = async (collection: Collection) => {
|
||||
await super.delete(collection);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import path from "path";
|
||||
import invariant from "invariant";
|
||||
import { find, orderBy, filter, compact, omitBy } from "lodash";
|
||||
import { observable, action, computed, runInAction } from "mobx";
|
||||
@@ -8,7 +9,13 @@ import naturalSort from "shared/utils/naturalSort";
|
||||
import BaseStore from "stores/BaseStore";
|
||||
import RootStore from "stores/RootStore";
|
||||
import Document from "models/Document";
|
||||
import type { FetchOptions, PaginationParams, SearchResult } from "types";
|
||||
import env from "env";
|
||||
import type {
|
||||
NavigationNode,
|
||||
FetchOptions,
|
||||
PaginationParams,
|
||||
SearchResult,
|
||||
} from "types";
|
||||
import { client } from "utils/ApiClient";
|
||||
|
||||
type ImportOptions = {
|
||||
@@ -23,6 +30,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
|
||||
importFileTypes: string[] = [
|
||||
".md",
|
||||
".doc",
|
||||
".docx",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
@@ -443,30 +452,30 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
fetch = async (
|
||||
id: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<?Document> => {
|
||||
): Promise<{ document: ?Document, sharedTree?: NavigationNode }> => {
|
||||
if (!options.prefetch) this.isFetching = true;
|
||||
|
||||
try {
|
||||
const doc: ?Document = this.data.get(id) || this.getByUrl(id);
|
||||
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
|
||||
if (doc && policy && !options.force) {
|
||||
return doc;
|
||||
return { document: doc };
|
||||
}
|
||||
|
||||
const res = await client.post("/documents.info", {
|
||||
id,
|
||||
shareId: options.shareId,
|
||||
apiVersion: 2,
|
||||
});
|
||||
invariant(res && res.data, "Document not available");
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
this.add(res.data);
|
||||
this.add(res.data.document);
|
||||
|
||||
runInAction("DocumentsStore#fetch", () => {
|
||||
this.isLoaded = true;
|
||||
});
|
||||
|
||||
return this.data.get(res.data.id);
|
||||
return {
|
||||
document: this.data.get(res.data.document.id),
|
||||
sharedTree: res.data.sharedTree,
|
||||
};
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
@@ -529,6 +538,19 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
collectionId: string,
|
||||
options: ImportOptions
|
||||
) => {
|
||||
// file.type can be an empty string sometimes
|
||||
if (
|
||||
file.type &&
|
||||
!this.importFileTypes.includes(file.type) &&
|
||||
!this.importFileTypes.includes(path.extname(file.name))
|
||||
) {
|
||||
throw new Error(`The selected file type is not supported (${file.type})`);
|
||||
}
|
||||
|
||||
if (file.size > env.MAXIMUM_IMPORT_SIZE) {
|
||||
throw new Error("The selected file was too large to import");
|
||||
}
|
||||
|
||||
const title = file.name.replace(/\.[^/.]+$/, "");
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -594,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
}
|
||||
|
||||
@action
|
||||
async delete(document: Document) {
|
||||
await super.delete(document);
|
||||
async delete(document: Document, options?: {| permanent: boolean |}) {
|
||||
await super.delete(document, options);
|
||||
|
||||
// check to see if we have any shares related to this document already
|
||||
// loaded in local state. If so we can go ahead and remove those too.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user