mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b5978ba1 | |||
| 33c468d2be | |||
| 45f1606fda | |||
| 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 | |||
| aa3cb22703 | |||
| 49ffcda8e0 | |||
| 77d6adb73b | |||
| 4d68a34897 | |||
| 61b2e63a44 | |||
| ae940dd255 | |||
| b13626631c | |||
| 7221e51b96 | |||
| b89f4c36f4 | |||
| 829cc14d36 | |||
| 8009e8f691 | |||
| ab2aaf7b7b | |||
| 65b4480e93 | |||
| 6de793e94e | |||
| 2d22399bbc | |||
| 3fbb3a2403 | |||
| d45178cb44 | |||
| 5786a03f33 | |||
| 011a1383ec | |||
| 72d7b5734d | |||
| b6fe3cb556 | |||
| 1e2224cb0d | |||
| 0477060b35 | |||
| a261abcdef | |||
| f64d0ce660 | |||
| f27072d06e | |||
| c8055e40bb | |||
| cfae180093 | |||
| 094c6418c9 | |||
| 99b1bf0ecb | |||
| 3b696cfa9a | |||
| eb6acdae20 | |||
| a818c7a924 | |||
| d157e9bfcd | |||
| f2052c2a05 | |||
| 40b4270e35 | |||
| 16c60a0d59 | |||
| 1a183ba0fc | |||
| 2ffc0ae81c | |||
| 50fdd73610 | |||
| a134773d4e | |||
| 317c52df62 | |||
| 04b8d7ae7b | |||
| 3569d2fee7 | |||
| ab267ce38d | |||
| fa52bc5afd | |||
| bf668d6347 | |||
| 7f9cba9819 | |||
| e9f083feb8 | |||
| 03d90b3f15 | |||
| 2432b4dcbd | |||
| 2c2c1341f7 | |||
| 7a8ccdb229 | |||
| b2d703bee4 | |||
| c46a032f0b | |||
| 940ad8479e | |||
| c5401a467d | |||
| 1dd97c1ddd | |||
| f37371c16e | |||
| 62f9262b2c | |||
| bc4fe05147 | |||
| cdc7f61fa1 | |||
| 2a6dfdea5d | |||
| de25ea0ed9 | |||
| d2227a2488 | |||
| 3e050727cb | |||
| 326518873e | |||
| ed779a250f | |||
| 190f0b6dc5 | |||
| 1a889e9913 | |||
| b3203857d7 | |||
| 5762fb33d9 | |||
| 1101ea428b | |||
| b4213e498c | |||
| f9f76d4438 | |||
| 4a9571a174 | |||
| bf856dbafa | |||
| 0e54302d75 | |||
| 4777176d84 | |||
| 3ffa21b07f | |||
| 8cbc873451 | |||
| d2e8311b39 | |||
| 810257bcf5 | |||
| 2ef0caba88 | |||
| 2e64972574 | |||
| 7e1b07ef98 | |||
| d7acf616cf | |||
| c5569bd365 | |||
| 25023fb086 |
@@ -6,7 +6,7 @@
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"corejs": {
|
||||
"version": "2",
|
||||
"version": "3",
|
||||
"proposals": true
|
||||
},
|
||||
"useBuiltIns": "usage"
|
||||
|
||||
+42
-25
@@ -8,18 +8,21 @@
|
||||
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
|
||||
# DO NOT LEAVE UNSET
|
||||
# Generate a unique 32 character hexadecimal key. The format is important as this
|
||||
# value is fed directly into encryption libraries. 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
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
@@ -27,8 +30,29 @@ REDIS_URL=redis://localhost:6479
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# Third party signin credentials, at least one of EITHER Google OR Slack is
|
||||
# required for a working installation or you'll have no sign-in options.
|
||||
# To support uploading of images for avatars and document attachments an
|
||||
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
|
||||
# however if you want to keep all file storage local an alternative such as
|
||||
# minio (https://github.com/minio/minio) can be used.
|
||||
|
||||
# A more detailed guide on setting up S3 is available here:
|
||||
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
|
||||
#
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
AWS_S3_ACL=private
|
||||
|
||||
|
||||
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
||||
|
||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||
# or Microsoft is required for a working installation or you'll have no sign-in
|
||||
# options.
|
||||
|
||||
# To configure Slack auth, you'll need to create an Application at
|
||||
# => https://api.slack.com/apps
|
||||
@@ -46,6 +70,12 @@ SLACK_SECRET=get_the_secret_of_above_key
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
|
||||
# the guide for details on setting up your Azure App:
|
||||
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
|
||||
AZURE_CLIENT_ID=
|
||||
AZURE_CLIENT_SECRET=
|
||||
AZURE_RESOURCE_APP_ID=
|
||||
|
||||
|
||||
|
||||
@@ -65,9 +95,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
|
||||
@@ -87,23 +121,6 @@ GOOGLE_ANALYTICS_ID=
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance
|
||||
SENTRY_DSN=
|
||||
|
||||
# To support uploading of images for avatars and document attachments an
|
||||
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
|
||||
# however if you want to keep all file storage local an alternative such as
|
||||
# minio (https://github.com/minio/minio) can be used.
|
||||
|
||||
# A more detailed guide on setting up S3 is available here:
|
||||
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
|
||||
#
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_HOST=
|
||||
@@ -118,4 +135,4 @@ SMTP_REPLY_EMAIL=
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [outline]
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
# 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:
|
||||
- security
|
||||
- pinned
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.51.0
|
||||
Licensed Work: Outline 0.55.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2023-12-13
|
||||
Change Date: 2024-04-22
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ yarn run upgrade
|
||||
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install these dependencies if you don't already have them
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. Clone this repo
|
||||
1. Register a Slack app at https://api.slack.com/apps
|
||||
1. Copy the file `.env.sample` to `.env`
|
||||
|
||||
@@ -33,7 +33,7 @@ const Actions = styled(Flex)`
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
padding: 12px;
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
fill?: string,
|
||||
className?: string,
|
||||
};
|
||||
|
||||
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0002 1H33.9998C33.9998 5.8172 34.0007 10.6344 33.9988 15.4516C28.6666 15.4508 23.3334 15.4516 18.0012 15.4516C17.9993 10.6344 18.0002 5.8172 18.0002 1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0009 17.5173C23.3333 17.5155 28.6667 17.5164 34 17.5164V33H18C18.0009 27.8388 17.9991 22.6776 18.0009 17.5173V17.5173Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 1H16L15.9988 15.4516H0V1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 17.5161C5.3332 17.5179 10.6664 17.5155 15.9996 17.5179C16.0005 22.6789 15.9996 27.839 15.9996 33H0V17.5161Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MicrosoftLogo;
|
||||
@@ -1,19 +1,46 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import SlackLogo from "../SlackLogo";
|
||||
import styled from "styled-components";
|
||||
import GoogleLogo from "./GoogleLogo";
|
||||
import MicrosoftLogo from "./MicrosoftLogo";
|
||||
import SlackLogo from "./SlackLogo";
|
||||
|
||||
type Props = {|
|
||||
providerName: string,
|
||||
size?: number,
|
||||
|};
|
||||
|
||||
export default function AuthLogo({ providerName }: Props) {
|
||||
function AuthLogo({ providerName, size = 16 }: Props) {
|
||||
switch (providerName) {
|
||||
case "slack":
|
||||
return <SlackLogo size={16} />;
|
||||
return (
|
||||
<Logo>
|
||||
<SlackLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
case "google":
|
||||
return <GoogleLogo size={16} />;
|
||||
return (
|
||||
<Logo>
|
||||
<GoogleLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
case "azure":
|
||||
return (
|
||||
<Logo>
|
||||
<MicrosoftLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default AuthLogo;
|
||||
|
||||
@@ -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
-169
@@ -1,204 +1,87 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
PadlockIcon,
|
||||
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.private && (
|
||||
<>
|
||||
<SmallPadlockIcon color="currentColor" size={16} />{" "}
|
||||
</>
|
||||
)}
|
||||
{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 SmallPadlockIcon = styled(PadlockIcon)`
|
||||
display: inline-block;
|
||||
vertical-align: sub;
|
||||
`;
|
||||
|
||||
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;
|
||||
|
||||
+27
-25
@@ -134,30 +134,32 @@ export type Props = {|
|
||||
"data-event-action"?: string,
|
||||
|};
|
||||
|
||||
function Button({
|
||||
type = "text",
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
neutral,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
const Button = React.forwardRef<Props, HTMLButtonElement>(
|
||||
(
|
||||
{
|
||||
type = "text",
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
neutral,
|
||||
...rest
|
||||
}: Props,
|
||||
innerRef
|
||||
) => {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default React.forwardRef<Props, typeof Button>((props, ref) => (
|
||||
<Button {...props} innerRef={ref} />
|
||||
));
|
||||
export default Button;
|
||||
|
||||
@@ -21,6 +21,10 @@ const Container = styled.div`
|
||||
const Content = styled.div`
|
||||
max-width: 46em;
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: 52em;
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent = ({ children, ...rest }: Props) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {|
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
@@ -47,12 +48,13 @@ const MenuItem = ({
|
||||
{(props) => (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$toggleable={selected !== undefined}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
@@ -63,16 +65,17 @@ const MenuItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 6px 12px;
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
@@ -80,7 +83,7 @@ export const MenuAnchor = styled.a`
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
@@ -115,6 +118,11 @@ export const MenuAnchor = styled.a`
|
||||
background: ${props.theme.primary};
|
||||
}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
|
||||
font-size: 15px;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// @flow
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeAndScaleIn,
|
||||
fadeAndSlideIn,
|
||||
} from "shared/styles/animations";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
@@ -37,41 +42,80 @@ export default function ContextMenu({
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
return (
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
)}
|
||||
</Menu>
|
||||
{(rest.visible || rest.animating) && (
|
||||
<Portal>
|
||||
<Backdrop />
|
||||
</Portal>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
z-index: ${(props) => props.theme.depths.menu - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
animation: ${fadeAndSlideIn} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
max-width: 276px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) =>
|
||||
props.left !== undefined ? "25%" : "75%"} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Divider = styled.hr`
|
||||
border: 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export default Divider;
|
||||
@@ -0,0 +1,137 @@
|
||||
// @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",
|
||||
};
|
||||
}
|
||||
|
||||
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.id),
|
||||
});
|
||||
}
|
||||
|
||||
path.forEach((p) => {
|
||||
output.push({
|
||||
title: p.title,
|
||||
to: p.url,
|
||||
});
|
||||
});
|
||||
|
||||
return output;
|
||||
}, [path, category, collection]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -15,7 +15,9 @@ import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "components/Star";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -41,7 +43,9 @@ function replaceResultMarks(tag: string) {
|
||||
|
||||
function DocumentListItem(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
const currentTeam = useCurrentTeam();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const {
|
||||
document,
|
||||
@@ -60,6 +64,7 @@ function DocumentListItem(props: Props) {
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = policies.abilities(currentTeam.id);
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
@@ -111,21 +116,24 @@ function DocumentListItem(props: Props) {
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate && !document.isArchived && !document.isDeleted && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
@@ -142,7 +142,7 @@ function DocumentMeta({
|
||||
<span>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// @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 = {|
|
||||
@@ -12,22 +16,41 @@ type Props = {|
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
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();
|
||||
|
||||
React.useEffect(() => {
|
||||
views.fetchPage({ documentId: document.id });
|
||||
}, [views, document.id]);
|
||||
|
||||
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: distanceInWordsToNow(
|
||||
view ? new Date(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);
|
||||
@@ -1,123 +0,0 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
let importingLock = false;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
location: Object,
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DropToImport extends React.Component<Props> {
|
||||
@observable isImporting: boolean = false;
|
||||
|
||||
onDropAccepted = async (files = []) => {
|
||||
if (importingLock) return;
|
||||
|
||||
this.isImporting = true;
|
||||
importingLock = true;
|
||||
|
||||
try {
|
||||
let collectionId = this.props.collectionId;
|
||||
const documentId = this.props.documentId;
|
||||
const redirect = files.length === 1;
|
||||
|
||||
if (documentId && !collectionId) {
|
||||
const document = await this.props.documents.fetch(documentId);
|
||||
invariant(document, "Document not available");
|
||||
collectionId = document.collectionId;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await this.props.documents.import(
|
||||
file,
|
||||
documentId,
|
||||
collectionId,
|
||||
{ publish: true }
|
||||
);
|
||||
|
||||
if (redirect) {
|
||||
this.props.history.push(doc.url);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { documents } = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
noClick
|
||||
multiple
|
||||
>
|
||||
{({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DropzoneContainer = styled("div")`
|
||||
border-radius: 4px;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
css`
|
||||
background: ${theme.slateDark};
|
||||
a {
|
||||
color: ${theme.white} !important;
|
||||
}
|
||||
svg {
|
||||
fill: ${theme.white};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default inject("documents", "ui")(withRouter(DropToImport));
|
||||
@@ -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(
|
||||
@@ -121,6 +133,11 @@ function Editor(props: PropsWithRef) {
|
||||
deleteColumn: t("Delete column"),
|
||||
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"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
findOrCreateDoc: `${t("Find or create a doc")}…`,
|
||||
@@ -141,6 +158,7 @@ function Editor(props: PropsWithRef) {
|
||||
noResults: t("No results"),
|
||||
openLink: t("Open link"),
|
||||
orderedList: t("Ordered list"),
|
||||
pageBreak: t("Page break"),
|
||||
pasteLink: `${t("Paste a link")}…`,
|
||||
pasteLinkWithTitle: (service: string) =>
|
||||
t("Paste a {{service}} link…", { service }),
|
||||
@@ -170,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,
|
||||
@@ -30,12 +31,12 @@ const FilterOptions = ({
|
||||
className,
|
||||
onSelect,
|
||||
}: Props) => {
|
||||
const menu = useMenuState();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const selected = find(options, { key: activeKey }) || options[0];
|
||||
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;
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
@@ -17,7 +18,8 @@ type Props = {
|
||||
group: Group,
|
||||
groupMemberships: GroupMembershipsStore,
|
||||
membership?: CollectionGroupMembership,
|
||||
showFacepile: boolean,
|
||||
showFacepile?: boolean,
|
||||
showAvatar?: boolean,
|
||||
renderActions: ({ openMembersModal: () => void }) => React.Node,
|
||||
};
|
||||
|
||||
@@ -48,6 +50,11 @@ class GroupListItem extends React.Component<Props> {
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={
|
||||
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
|
||||
}
|
||||
@@ -84,6 +91,15 @@ class GroupListItem extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => void,
|
||||
|};
|
||||
|
||||
const Guide = ({
|
||||
children,
|
||||
isOpen,
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({ animated: 250 });
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
dialog.show();
|
||||
}
|
||||
if (wasOpen && !isOpen) {
|
||||
dialog.hide();
|
||||
}
|
||||
}, [dialog, wasOpen, isOpen]);
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
<Scene {...props} {...rest}>
|
||||
<Content>
|
||||
{title && <Header>{title}</Header>}
|
||||
{children}
|
||||
</Content>
|
||||
</Scene>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = styled.h1`
|
||||
font-size: 18px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.backdrop} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Scene = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 12px;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
transition: transform 250ms ease, opacity 250ms ease;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
export default observer(Guide);
|
||||
@@ -77,7 +77,7 @@ const Actions = styled(Flex)`
|
||||
const Wrapper = styled(Flex)`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: ${(props) => props.theme.depths.header};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
@@ -97,6 +97,7 @@ const Wrapper = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Title = styled("div")`
|
||||
display: none;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
@@ -105,12 +106,9 @@ const Title = styled("div")`
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
|
||||
/* on mobile, there's always a floating menu button in the top left
|
||||
add some padding here to offset
|
||||
*/
|
||||
padding-left: 40px;
|
||||
${breakpoint("tablet")`
|
||||
padding-left: 0;
|
||||
display: block;
|
||||
`};
|
||||
|
||||
svg {
|
||||
|
||||
@@ -201,6 +201,7 @@ const Card = styled.div`
|
||||
const Position = styled.div`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${(props) => props.theme.depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -35,6 +35,10 @@ const RealInput = styled.input`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
font-size: 16px;
|
||||
`};
|
||||
@@ -102,8 +106,9 @@ export type Props = {|
|
||||
onChange?: (
|
||||
ev: SyntheticInputEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
onKeyDown?: (ev: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => mixed,
|
||||
onBlur?: (ev: SyntheticEvent<>) => mixed,
|
||||
|};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -3,10 +3,12 @@ import styled from "styled-components";
|
||||
import Input from "./Input";
|
||||
|
||||
const InputLarge = styled(Input)`
|
||||
height: 40px;
|
||||
height: 38px;
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
|
||||
input {
|
||||
height: 40px;
|
||||
height: 38px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,89 +1,48 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Input, { type Props as InputProps } from "./Input";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Theme,
|
||||
source: string,
|
||||
type Props = {|
|
||||
...InputProps,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
collectionId?: string,
|
||||
t: TFunction,
|
||||
};
|
||||
value?: string,
|
||||
onChange: (event: SyntheticInputEvent<>) => mixed,
|
||||
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class InputSearch extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
export default function InputSearch(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
const handleFocus = React.useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
if (this.input) {
|
||||
this.input.focus();
|
||||
}
|
||||
}
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setIsFocused(false);
|
||||
}, []);
|
||||
|
||||
handleSearchInput = (ev: SyntheticInputEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, {
|
||||
collectionId: this.props.collectionId,
|
||||
ref: this.props.source,
|
||||
})
|
||||
);
|
||||
};
|
||||
const { placeholder = `${t("Search")}…`, onKeyDown, ...rest } = props;
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { theme, placeholder = `${t("Search")}…` } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={(ref) => (this.input = ref)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleSearchInput}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
labelHidden={this.props.labelHidden}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={isFocused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTranslation()<InputSearch>(
|
||||
withTheme(withRouter(InputSearch))
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Theme,
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
collectionId?: string,
|
||||
value: string,
|
||||
onChange: (event: SyntheticInputEvent<>) => mixed,
|
||||
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputSearchPage extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.input) {
|
||||
this.input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchInput = (ev: SyntheticInputEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, {
|
||||
collectionId: this.props.collectionId,
|
||||
ref: this.props.source,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, value, onChange, onKeyDown } = this.props;
|
||||
const { theme, placeholder = `${t("Search")}…` } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={(ref) => (this.input = ref)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleSearchInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTranslation()<InputSearchPage>(
|
||||
withTheme(withRouter(InputSearchPage))
|
||||
);
|
||||
@@ -27,7 +27,7 @@ const Wrapper = styled.label`
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
type Option = { label: string, value: string };
|
||||
export type Option = { label: string, value: string };
|
||||
|
||||
export type Props = {
|
||||
value?: string,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputSelect, { type Props, type Option } from "./InputSelect";
|
||||
|
||||
export default function InputSelectPermission(
|
||||
props: $Rest<Props, { options: Array<Option> }>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
label={t("Default access")}
|
||||
options={[
|
||||
{ label: t("View and edit"), value: "read_write" },
|
||||
{ label: t("View only"), value: "read" },
|
||||
{ label: t("No access"), value: "" },
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,12 +17,10 @@ const Labeled = ({ label, children, ...props }: Props) => (
|
||||
);
|
||||
|
||||
export const Label = styled(Flex)`
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
letter-spacing: 0.04em;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default observer(Labeled);
|
||||
|
||||
@@ -24,8 +24,8 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Button from "components/Button";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import Flex from "components/Flex";
|
||||
import Guide from "components/Guide";
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import Modal from "components/Modal";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
@@ -161,13 +161,13 @@ class Layout extends React.Component<Props> {
|
||||
/>
|
||||
</Switch>
|
||||
</Container>
|
||||
<Modal
|
||||
<Guide
|
||||
isOpen={this.keyboardShortcutsOpen}
|
||||
onRequestClose={this.handleCloseKeyboardShortcuts}
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
</Guide>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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")`
|
||||
|
||||
+27
-12
@@ -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: ${(props) => (props.compact ? "8px" : "12px")} 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;
|
||||
@@ -38,16 +49,19 @@ const Wrapper = styled.li`
|
||||
|
||||
const Image = styled(Flex)`
|
||||
padding: 0 8px 0 0;
|
||||
max-height: 40px;
|
||||
max-height: 32px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
align-self: center;
|
||||
`;
|
||||
|
||||
const Heading = styled.p`
|
||||
font-size: 16px;
|
||||
font-size: ${(props) => (props.$small ? 15 : 16)}px;
|
||||
font-weight: 500;
|
||||
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`
|
||||
|
||||
@@ -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,13 +19,13 @@ 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() {
|
||||
return <Redacted width={this.width} />;
|
||||
return <Redacted width={this.width} height={this.props.height} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-81
@@ -4,15 +4,17 @@ import { CloseIcon, BackIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReactModal from "react-modal";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useUnmount from "hooks/useUnmount";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
let openModals = 0;
|
||||
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
@@ -21,44 +23,6 @@ type Props = {|
|
||||
onRequestClose: () => void,
|
||||
|};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
background-color: ${(props) =>
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
.ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 12px;
|
||||
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
|
||||
border-radius: 8px 0 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 36px;
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
.ReactModal__Body--open {
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
const Modal = ({
|
||||
children,
|
||||
isOpen,
|
||||
@@ -66,36 +30,112 @@ const Modal = ({
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({ animated: 250 });
|
||||
const [depth, setDepth] = React.useState(0);
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
setDepth(openModals++);
|
||||
dialog.show();
|
||||
}
|
||||
if (wasOpen && !isOpen) {
|
||||
setDepth(openModals--);
|
||||
dialog.hide();
|
||||
}
|
||||
}, [dialog, wasOpen, isOpen]);
|
||||
|
||||
useUnmount(() => {
|
||||
if (isOpen) {
|
||||
openModals--;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<StyledModal
|
||||
contentLabel={title}
|
||||
onRequestClose={onRequestClose}
|
||||
isOpen={isOpen}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
<Text>{t("Back")}</Text>
|
||||
</Back>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} color="currentColor" />
|
||||
</Close>
|
||||
</StyledModal>
|
||||
</>
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
<Scene
|
||||
$nested={!!depth}
|
||||
style={{ marginLeft: `${depth * 12}px` }}
|
||||
{...props}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
<Text>{t("Back")}</Text>
|
||||
</Back>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} color="currentColor" />
|
||||
</Close>
|
||||
</Scene>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
);
|
||||
};
|
||||
|
||||
const Backdrop = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) =>
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Scene = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props) =>
|
||||
props.$nested &&
|
||||
`
|
||||
box-shadow: 0 -2px 10px ${props.theme.shadow};
|
||||
border-radius: 8px 0 0 8px;
|
||||
overflow: hidden;
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 8vh 2rem 2rem;
|
||||
@@ -112,23 +152,6 @@ const Centered = styled(Flex)`
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const StyledModal = styled(ReactModal)`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const Text = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,17 +19,6 @@ export default function PageTheme() {
|
||||
themeElement.setAttribute("content", theme.background);
|
||||
}
|
||||
|
||||
// status bar color for iOS PWA
|
||||
const statusElement = document.querySelector(
|
||||
'meta[name="apple-mobile-web-app-status-bar-style"]'
|
||||
);
|
||||
if (statusElement) {
|
||||
statusElement.setAttribute(
|
||||
"content",
|
||||
ui.resolvedTheme === "dark" ? "black-translucent" : "default"
|
||||
);
|
||||
}
|
||||
|
||||
// user-agent controls and scrollbars
|
||||
const csElement = document.querySelector('meta[name="color-scheme"]');
|
||||
if (csElement) {
|
||||
|
||||
@@ -19,12 +19,16 @@ const PageTitle = ({ title, favicon }: Props) => {
|
||||
<title>
|
||||
{team && team.name ? `${title} - ${team.name}` : `${title} - Outline`}
|
||||
</title>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={favicon || cdnPath("/favicon-32.png")}
|
||||
sizes="32x32"
|
||||
/>
|
||||
{favicon ? (
|
||||
<link rel="shortcut icon" href={favicon} />
|
||||
) : (
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={cdnPath("/favicon-32.png")}
|
||||
sizes="32x32"
|
||||
/>
|
||||
)}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -12,6 +12,7 @@ type Props = {|
|
||||
children: React.Node,
|
||||
breadcrumb?: React.Node,
|
||||
actions?: React.Node,
|
||||
centered?: boolean,
|
||||
|};
|
||||
|
||||
function Scene({
|
||||
@@ -21,6 +22,7 @@ function Scene({
|
||||
actions,
|
||||
breadcrumb,
|
||||
children,
|
||||
centered,
|
||||
}: Props) {
|
||||
return (
|
||||
<FillWidth>
|
||||
@@ -38,7 +40,11 @@ function Scene({
|
||||
actions={actions}
|
||||
breadcrumb={breadcrumb}
|
||||
/>
|
||||
<CenteredContent withStickyHeader>{children}</CenteredContent>
|
||||
{centered !== false ? (
|
||||
<CenteredContent withStickyHeader>{children}</CenteredContent>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</FillWidth>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,32 +114,36 @@ function MainSidebar() {
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Collections
|
||||
@@ -175,7 +179,7 @@ function MainSidebar() {
|
||||
/>
|
||||
{can.inviteUser && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
to="/settings/members"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
|
||||
@@ -19,14 +19,14 @@ import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import Scrollable from "components/Scrollable";
|
||||
|
||||
import SlackIcon from "components/SlackIcon";
|
||||
import ZapierIcon from "components/ZapierIcon";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import TeamButton from "./components/TeamButton";
|
||||
import Version from "./components/Version";
|
||||
import SlackIcon from "./icons/Slack";
|
||||
import ZapierIcon from "./icons/Zapier";
|
||||
import env from "env";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -71,11 +71,13 @@ function SettingsSidebar() {
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
{can.createApiKey && (
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>{t("Team")}</Header>
|
||||
@@ -94,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"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Portal } from "react-portal";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { fadeIn } from "shared/styles/animations";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
@@ -154,9 +155,7 @@ const Sidebar = React.forwardRef<Props, HTMLButtonElement>(
|
||||
<>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
<Backdrop onClick={ui.toggleMobileSidebar} />
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
@@ -203,7 +202,8 @@ const Sidebar = React.forwardRef<Props, HTMLButtonElement>(
|
||||
}
|
||||
);
|
||||
|
||||
const Background = styled.a`
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -211,7 +211,7 @@ const Background = styled.a`
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)`
|
||||
@@ -242,7 +242,6 @@ const Container = styled(Flex)`
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
transform: translateX(${(props) =>
|
||||
props.$collapsed ? "calc(-100% + 16px)" : 0});
|
||||
|
||||
@@ -7,9 +7,9 @@ import styled from "styled-components";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -13,24 +13,45 @@ import CollectionsLoading from "./CollectionsLoading";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
type Props = {
|
||||
onCreateCollection: () => void,
|
||||
};
|
||||
|
||||
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();
|
||||
const team = useCurrentTeam();
|
||||
const orderedCollections = collections.orderedData;
|
||||
const can = policies.abilities(team.id);
|
||||
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!collections.isLoaded) {
|
||||
collections.fetchPage({ limit: 100 });
|
||||
async function load() {
|
||||
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, ui, fetchError, t]);
|
||||
|
||||
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
|
||||
accept: "collection",
|
||||
@@ -68,17 +89,19 @@ function Collections({ onCreateCollection }: Props) {
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
))}
|
||||
<SidebarLink
|
||||
to="/collections"
|
||||
onClick={onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("New collection")}…`}
|
||||
exact
|
||||
/>
|
||||
{can.createCollection && (
|
||||
<SidebarLink
|
||||
to="/collections"
|
||||
onClick={onCreateCollection}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("New collection")}…`}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
if (!collections.isLoaded || fetchError) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header>{t("Collections")}</Header>
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import Fade from "components/Fade";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
@@ -282,6 +282,7 @@ const Draggable = styled("div")`
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
transition: transform 100ms ease, fill 50ms !important;
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import useImportDocument from "hooks/useImportDocument";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
disabled: boolean,
|
||||
staticContext: Object,
|
||||
|};
|
||||
|
||||
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, documents, policies } = useStores();
|
||||
const { handleFiles, isImporting } = useImportDocument(
|
||||
collectionId,
|
||||
documentId
|
||||
);
|
||||
|
||||
const can = policies.abilities(collectionId);
|
||||
|
||||
const handleRejection = React.useCallback(() => {
|
||||
ui.showToast(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
|
||||
{ type: "error" }
|
||||
);
|
||||
}, [t, ui]);
|
||||
|
||||
if (disabled || !can.update) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={handleFiles}
|
||||
onDropRejected={handleRejection}
|
||||
noClick
|
||||
multiple
|
||||
>
|
||||
{({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isImporting && <LoadingIndicator />}
|
||||
{children}
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
|
||||
const DropzoneContainer = styled.div`
|
||||
border-radius: 4px;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
css`
|
||||
background: ${theme.slateDark};
|
||||
a {
|
||||
color: ${theme.white} !important;
|
||||
}
|
||||
svg {
|
||||
fill: ${theme.white};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(DropToImport);
|
||||
@@ -66,22 +66,24 @@ function SidebarLink({
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
<>
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
</Link>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +111,8 @@ const Actions = styled(EventBoundary)`
|
||||
}
|
||||
|
||||
&:hover {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
@@ -126,7 +130,7 @@ const Link = styled(NavLink)`
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -135,30 +139,32 @@ 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,
|
||||
&:active {
|
||||
> ${Actions} {
|
||||
display: inline-flex;
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 32px 4px 16px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
@media (hover: hover) {
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 16px;
|
||||
`}
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
|
||||
@@ -68,18 +68,19 @@ const Wrapper = styled.div`
|
||||
const Header = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
background: none;
|
||||
line-height: inherit;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
margin: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
width: calc(100% - 16px);
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
transition: background 100ms ease-in-out;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
background: ${(props) => props.theme.sidebarItemBackground};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -101,7 +101,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.on("reconnect_attempt", () => {
|
||||
this.socket.io.opts.transports = ["polling", "websocket"];
|
||||
this.socket.io.opts.transports =
|
||||
auth.team && auth.team.domain
|
||||
? ["websocket"]
|
||||
: ["websocket", "polling"];
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
@@ -141,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);
|
||||
|
||||
@@ -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);
|
||||
+22
-13
@@ -1,25 +1,34 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import GlobalStyles from "shared/styles/globals";
|
||||
import { dark, light } from "shared/styles/theme";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { dark, light, lightMobile, darkMobile } from "shared/styles/theme";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
const empty = {};
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
function Theme({ children }: Props) {
|
||||
const { ui } = useStores();
|
||||
const theme = ui.resolvedTheme === "dark" ? dark : light;
|
||||
const mobileTheme = ui.resolvedTheme === "dark" ? darkMobile : lightMobile;
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.tablet}px)`);
|
||||
|
||||
function Theme({ children, ui }: Props) {
|
||||
return (
|
||||
<ThemeProvider theme={ui.resolvedTheme === "dark" ? dark : light}>
|
||||
<>
|
||||
<GlobalStyles />
|
||||
{children}
|
||||
</>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeProvider theme={isMobile ? mobileTheme : empty}>
|
||||
<>
|
||||
<GlobalStyles />
|
||||
{children}
|
||||
</>
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("ui")(observer(Theme));
|
||||
export default observer(Theme);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import env from "env";
|
||||
|
||||
const URL_REGEX = new RegExp("^https://www.dropbox.com/sh?/(.*)$");
|
||||
|
||||
type Props = {|
|
||||
isSelected: boolean,
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
class Dropbox extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
container = React.createRef<HTMLAnchorElement>();
|
||||
|
||||
shouldComponentUpdate(nextProps: Props) {
|
||||
return (
|
||||
nextProps.isSelected !== this.props.isSelected ||
|
||||
nextProps.attrs.href !== this.props.attrs.href
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (document.getElementById("dropboxjs")) {
|
||||
if (this.container.current) {
|
||||
window.Dropbox.embed(
|
||||
{ link: this.props.attrs.href },
|
||||
this.container.current
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = document.createElement("script");
|
||||
tag.async = false;
|
||||
tag.id = "dropboxjs";
|
||||
tag.setAttribute("data-app-key", env.DROPBOX_APP_KEY);
|
||||
tag.src = "https://www.dropbox.com/static/api/2/dropins.js";
|
||||
document.body?.appendChild(tag);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Rounded
|
||||
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
|
||||
>
|
||||
<a
|
||||
ref={this.container}
|
||||
href={this.props.attrs.href}
|
||||
className="dropbox-embed"
|
||||
data-height="400px"
|
||||
/>
|
||||
</Rounded>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Rounded = styled.div`
|
||||
border-radius: 3px;
|
||||
height: 400px;
|
||||
`;
|
||||
|
||||
export default Dropbox;
|
||||
@@ -1,22 +1,22 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = /^https?:\/\/(www\.|app\.)?lucidchart.com\/documents\/(embeddedchart|view)\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\/.*)?$/;
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
matches: Object,
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class Lucidchart extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
static ENABLED = [
|
||||
/^https?:\/\/(www\.|app\.)?lucidchart.com\/documents\/(embeddedchart|view)\/(?<chartId>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\/.*)?$/,
|
||||
/^https?:\/\/(www\.|app\.)?lucid.app\/lucidchart\/(?<chartId>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/(embeddedchart|view)(?:\/.*)?$/,
|
||||
];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props.attrs;
|
||||
const chartId = matches[3];
|
||||
const { chartId } = matches.groups;
|
||||
|
||||
return (
|
||||
<Frame
|
||||
|
||||
+221
-187
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { filter } from "lodash";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Image from "components/Image";
|
||||
@@ -7,6 +8,8 @@ import Airtable from "./Airtable";
|
||||
import Cawemo from "./Cawemo";
|
||||
import ClickUp from "./ClickUp";
|
||||
import Codepen from "./Codepen";
|
||||
import Descript from "./Descript";
|
||||
import Dropbox from "./Dropbox";
|
||||
import Figma from "./Figma";
|
||||
import Framer from "./Framer";
|
||||
import Gist from "./Gist";
|
||||
@@ -28,6 +31,8 @@ import Trello from "./Trello";
|
||||
import Typeform from "./Typeform";
|
||||
import Vimeo from "./Vimeo";
|
||||
import YouTube from "./YouTube";
|
||||
import env from "env";
|
||||
import { isCustomDomain } from "utils/domains";
|
||||
|
||||
function matcher(Component) {
|
||||
return (url: string) => {
|
||||
@@ -47,190 +52,219 @@ const Img = styled(Image)`
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export default [
|
||||
{
|
||||
title: "Abstract",
|
||||
keywords: "design",
|
||||
icon: () => <Img src="/images/abstract.png" />,
|
||||
component: Abstract,
|
||||
matcher: matcher(Abstract),
|
||||
},
|
||||
{
|
||||
title: "Airtable",
|
||||
keywords: "spreadsheet",
|
||||
icon: () => <Img src="/images/airtable.png" />,
|
||||
component: Airtable,
|
||||
matcher: matcher(Airtable),
|
||||
},
|
||||
{
|
||||
title: "Cawemo",
|
||||
keywords: "bpmn process",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/cawemo.png" />,
|
||||
component: Cawemo,
|
||||
matcher: matcher(Cawemo),
|
||||
},
|
||||
{
|
||||
title: "ClickUp",
|
||||
keywords: "project",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/clickup.png" />,
|
||||
component: ClickUp,
|
||||
matcher: matcher(ClickUp),
|
||||
},
|
||||
{
|
||||
title: "Codepen",
|
||||
keywords: "code editor",
|
||||
icon: () => <Img src="/images/codepen.png" />,
|
||||
component: Codepen,
|
||||
matcher: matcher(Codepen),
|
||||
},
|
||||
{
|
||||
title: "Figma",
|
||||
keywords: "design svg vector",
|
||||
icon: () => <Img src="/images/figma.png" />,
|
||||
component: Figma,
|
||||
matcher: matcher(Figma),
|
||||
},
|
||||
{
|
||||
title: "Framer",
|
||||
keywords: "design prototyping",
|
||||
icon: () => <Img src="/images/framer.png" />,
|
||||
component: Framer,
|
||||
matcher: matcher(Framer),
|
||||
},
|
||||
{
|
||||
title: "GitHub Gist",
|
||||
keywords: "code",
|
||||
icon: () => <Img src="/images/github-gist.png" />,
|
||||
component: Gist,
|
||||
matcher: matcher(Gist),
|
||||
},
|
||||
{
|
||||
title: "Google Drawings",
|
||||
keywords: "drawings",
|
||||
icon: () => <Img src="/images/google-drawings.png" />,
|
||||
component: GoogleDrawings,
|
||||
matcher: matcher(GoogleDrawings),
|
||||
},
|
||||
{
|
||||
title: "Google Drive",
|
||||
keywords: "drive",
|
||||
icon: () => <Img src="/images/google-drive.png" />,
|
||||
component: GoogleDrive,
|
||||
matcher: matcher(GoogleDrive),
|
||||
},
|
||||
{
|
||||
title: "Google Docs",
|
||||
icon: () => <Img src="/images/google-docs.png" />,
|
||||
component: GoogleDocs,
|
||||
matcher: matcher(GoogleDocs),
|
||||
},
|
||||
{
|
||||
title: "Google Sheets",
|
||||
keywords: "excel spreadsheet",
|
||||
icon: () => <Img src="/images/google-sheets.png" />,
|
||||
component: GoogleSheets,
|
||||
matcher: matcher(GoogleSheets),
|
||||
},
|
||||
{
|
||||
title: "Google Slides",
|
||||
keywords: "presentation slideshow",
|
||||
icon: () => <Img src="/images/google-slides.png" />,
|
||||
component: GoogleSlides,
|
||||
matcher: matcher(GoogleSlides),
|
||||
},
|
||||
{
|
||||
title: "InVision",
|
||||
keywords: "design prototype",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/invision.png" />,
|
||||
component: InVision,
|
||||
matcher: matcher(InVision),
|
||||
},
|
||||
{
|
||||
title: "Loom",
|
||||
keywords: "video screencast",
|
||||
icon: () => <Img src="/images/loom.png" />,
|
||||
component: Loom,
|
||||
matcher: matcher(Loom),
|
||||
},
|
||||
{
|
||||
title: "Lucidchart",
|
||||
keywords: "chart",
|
||||
icon: () => <Img src="/images/lucidchart.png" />,
|
||||
component: Lucidchart,
|
||||
matcher: matcher(Lucidchart),
|
||||
},
|
||||
{
|
||||
title: "Marvel",
|
||||
keywords: "design prototype",
|
||||
icon: () => <Img src="/images/marvel.png" />,
|
||||
component: Marvel,
|
||||
matcher: matcher(Marvel),
|
||||
},
|
||||
{
|
||||
title: "Mindmeister",
|
||||
keywords: "mindmap",
|
||||
icon: () => <Img src="/images/mindmeister.png" />,
|
||||
component: Mindmeister,
|
||||
matcher: matcher(Mindmeister),
|
||||
},
|
||||
{
|
||||
title: "Miro",
|
||||
keywords: "whiteboard",
|
||||
icon: () => <Img src="/images/miro.png" />,
|
||||
component: Miro,
|
||||
matcher: matcher(Miro),
|
||||
},
|
||||
{
|
||||
title: "Mode",
|
||||
keywords: "analytics",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/mode-analytics.png" />,
|
||||
component: ModeAnalytics,
|
||||
matcher: matcher(ModeAnalytics),
|
||||
},
|
||||
{
|
||||
title: "Prezi",
|
||||
keywords: "presentation",
|
||||
icon: () => <Img src="/images/prezi.png" />,
|
||||
component: Prezi,
|
||||
matcher: matcher(Prezi),
|
||||
},
|
||||
{
|
||||
title: "Spotify",
|
||||
keywords: "music",
|
||||
icon: () => <Img src="/images/spotify.png" />,
|
||||
component: Spotify,
|
||||
matcher: matcher(Spotify),
|
||||
},
|
||||
{
|
||||
title: "Trello",
|
||||
keywords: "kanban",
|
||||
icon: () => <Img src="/images/trello.png" />,
|
||||
component: Trello,
|
||||
matcher: matcher(Trello),
|
||||
},
|
||||
{
|
||||
title: "Typeform",
|
||||
keywords: "form survey",
|
||||
icon: () => <Img src="/images/typeform.png" />,
|
||||
component: Typeform,
|
||||
matcher: matcher(Typeform),
|
||||
},
|
||||
{
|
||||
title: "Vimeo",
|
||||
keywords: "video",
|
||||
icon: () => <Img src="/images/vimeo.png" />,
|
||||
component: Vimeo,
|
||||
matcher: matcher(Vimeo),
|
||||
},
|
||||
{
|
||||
title: "YouTube",
|
||||
keywords: "google video",
|
||||
icon: () => <Img src="/images/youtube.png" />,
|
||||
component: YouTube,
|
||||
matcher: matcher(YouTube),
|
||||
},
|
||||
];
|
||||
type EmbedSpec = {
|
||||
title: string,
|
||||
keywords?: string,
|
||||
defaultHidden?: boolean,
|
||||
icon: any,
|
||||
component: React.ComponentType<any>,
|
||||
matcher: any,
|
||||
};
|
||||
|
||||
export default filter<void | EmbedSpec>(
|
||||
[
|
||||
{
|
||||
title: "Abstract",
|
||||
keywords: "design",
|
||||
icon: () => <Img src="/images/abstract.png" />,
|
||||
component: Abstract,
|
||||
matcher: matcher(Abstract),
|
||||
},
|
||||
{
|
||||
title: "Airtable",
|
||||
keywords: "spreadsheet",
|
||||
icon: () => <Img src="/images/airtable.png" />,
|
||||
component: Airtable,
|
||||
matcher: matcher(Airtable),
|
||||
},
|
||||
{
|
||||
title: "Cawemo",
|
||||
keywords: "bpmn process",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/cawemo.png" />,
|
||||
component: Cawemo,
|
||||
matcher: matcher(Cawemo),
|
||||
},
|
||||
{
|
||||
title: "ClickUp",
|
||||
keywords: "project",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/clickup.png" />,
|
||||
component: ClickUp,
|
||||
matcher: matcher(ClickUp),
|
||||
},
|
||||
{
|
||||
title: "Codepen",
|
||||
keywords: "code editor",
|
||||
icon: () => <Img src="/images/codepen.png" />,
|
||||
component: Codepen,
|
||||
matcher: matcher(Codepen),
|
||||
},
|
||||
{
|
||||
title: "Descript",
|
||||
keywords: "audio",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/descript.png" />,
|
||||
component: Descript,
|
||||
matcher: matcher(Descript),
|
||||
},
|
||||
env.DROPBOX_APP_KEY && !isCustomDomain()
|
||||
? {
|
||||
title: "Dropbox",
|
||||
keywords: "dropbox file pdf",
|
||||
icon: () => <Img src="/images/dropbox.png" />,
|
||||
component: Dropbox,
|
||||
matcher: matcher(Dropbox),
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
title: "Figma",
|
||||
keywords: "design svg vector",
|
||||
icon: () => <Img src="/images/figma.png" />,
|
||||
component: Figma,
|
||||
matcher: matcher(Figma),
|
||||
},
|
||||
{
|
||||
title: "Framer",
|
||||
keywords: "design prototyping",
|
||||
icon: () => <Img src="/images/framer.png" />,
|
||||
component: Framer,
|
||||
matcher: matcher(Framer),
|
||||
},
|
||||
{
|
||||
title: "GitHub Gist",
|
||||
keywords: "code",
|
||||
icon: () => <Img src="/images/github-gist.png" />,
|
||||
component: Gist,
|
||||
matcher: matcher(Gist),
|
||||
},
|
||||
{
|
||||
title: "Google Drawings",
|
||||
keywords: "drawings",
|
||||
icon: () => <Img src="/images/google-drawings.png" />,
|
||||
component: GoogleDrawings,
|
||||
matcher: matcher(GoogleDrawings),
|
||||
},
|
||||
{
|
||||
title: "Google Drive",
|
||||
keywords: "drive",
|
||||
icon: () => <Img src="/images/google-drive.png" />,
|
||||
component: GoogleDrive,
|
||||
matcher: matcher(GoogleDrive),
|
||||
},
|
||||
{
|
||||
title: "Google Docs",
|
||||
icon: () => <Img src="/images/google-docs.png" />,
|
||||
component: GoogleDocs,
|
||||
matcher: matcher(GoogleDocs),
|
||||
},
|
||||
{
|
||||
title: "Google Sheets",
|
||||
keywords: "excel spreadsheet",
|
||||
icon: () => <Img src="/images/google-sheets.png" />,
|
||||
component: GoogleSheets,
|
||||
matcher: matcher(GoogleSheets),
|
||||
},
|
||||
{
|
||||
title: "Google Slides",
|
||||
keywords: "presentation slideshow",
|
||||
icon: () => <Img src="/images/google-slides.png" />,
|
||||
component: GoogleSlides,
|
||||
matcher: matcher(GoogleSlides),
|
||||
},
|
||||
{
|
||||
title: "InVision",
|
||||
keywords: "design prototype",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/invision.png" />,
|
||||
component: InVision,
|
||||
matcher: matcher(InVision),
|
||||
},
|
||||
{
|
||||
title: "Loom",
|
||||
keywords: "video screencast",
|
||||
icon: () => <Img src="/images/loom.png" />,
|
||||
component: Loom,
|
||||
matcher: matcher(Loom),
|
||||
},
|
||||
{
|
||||
title: "Lucidchart",
|
||||
keywords: "chart",
|
||||
icon: () => <Img src="/images/lucidchart.png" />,
|
||||
component: Lucidchart,
|
||||
matcher: matcher(Lucidchart),
|
||||
},
|
||||
{
|
||||
title: "Marvel",
|
||||
keywords: "design prototype",
|
||||
icon: () => <Img src="/images/marvel.png" />,
|
||||
component: Marvel,
|
||||
matcher: matcher(Marvel),
|
||||
},
|
||||
{
|
||||
title: "Mindmeister",
|
||||
keywords: "mindmap",
|
||||
icon: () => <Img src="/images/mindmeister.png" />,
|
||||
component: Mindmeister,
|
||||
matcher: matcher(Mindmeister),
|
||||
},
|
||||
{
|
||||
title: "Miro",
|
||||
keywords: "whiteboard",
|
||||
icon: () => <Img src="/images/miro.png" />,
|
||||
component: Miro,
|
||||
matcher: matcher(Miro),
|
||||
},
|
||||
{
|
||||
title: "Mode",
|
||||
keywords: "analytics",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/mode-analytics.png" />,
|
||||
component: ModeAnalytics,
|
||||
matcher: matcher(ModeAnalytics),
|
||||
},
|
||||
{
|
||||
title: "Prezi",
|
||||
keywords: "presentation",
|
||||
icon: () => <Img src="/images/prezi.png" />,
|
||||
component: Prezi,
|
||||
matcher: matcher(Prezi),
|
||||
},
|
||||
{
|
||||
title: "Spotify",
|
||||
keywords: "music",
|
||||
icon: () => <Img src="/images/spotify.png" />,
|
||||
component: Spotify,
|
||||
matcher: matcher(Spotify),
|
||||
},
|
||||
{
|
||||
title: "Trello",
|
||||
keywords: "kanban",
|
||||
icon: () => <Img src="/images/trello.png" />,
|
||||
component: Trello,
|
||||
matcher: matcher(Trello),
|
||||
},
|
||||
{
|
||||
title: "Typeform",
|
||||
keywords: "form survey",
|
||||
icon: () => <Img src="/images/typeform.png" />,
|
||||
component: Typeform,
|
||||
matcher: matcher(Typeform),
|
||||
},
|
||||
{
|
||||
title: "Vimeo",
|
||||
keywords: "video",
|
||||
icon: () => <Img src="/images/vimeo.png" />,
|
||||
component: Vimeo,
|
||||
matcher: matcher(Vimeo),
|
||||
},
|
||||
{
|
||||
title: "YouTube",
|
||||
keywords: "google video",
|
||||
icon: () => <Img src="/images/youtube.png" />,
|
||||
component: YouTube,
|
||||
matcher: matcher(YouTube),
|
||||
},
|
||||
],
|
||||
(i: void | EmbedSpec) => !!i
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let importingLock = false;
|
||||
|
||||
export default function useImportDocument(
|
||||
collectionId: string,
|
||||
documentId?: string
|
||||
): {| handleFiles: (files: File[]) => Promise<void>, isImporting: boolean |} {
|
||||
const { documents, ui } = useStores();
|
||||
const [isImporting, setImporting] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
const handleFiles = React.useCallback(
|
||||
async (files = []) => {
|
||||
if (importingLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
importingLock = true;
|
||||
|
||||
try {
|
||||
let cId = collectionId;
|
||||
const redirect = files.length === 1;
|
||||
|
||||
if (documentId && !collectionId) {
|
||||
const { document } = await documents.fetch(documentId);
|
||||
invariant(document, "Document not available");
|
||||
cId = document.collectionId;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
history.push(doc.url);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ui.showToast(`${t("Could not import file")}. ${err.message}`, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
importingLock = false;
|
||||
}
|
||||
},
|
||||
[t, ui, documents, history, collectionId, documentId]
|
||||
);
|
||||
|
||||
return {
|
||||
handleFiles,
|
||||
isImporting,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
import { useTheme } from "styled-components";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
|
||||
export default function useMobile(): boolean {
|
||||
const theme = useTheme();
|
||||
return useMediaQuery(`(max-width: ${theme.breakpoints.tablet}px)`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// @flow
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export default function useQuery() {
|
||||
return new URLSearchParams(useLocation().search);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
const useUnmount = (callback: Function) => {
|
||||
const ref = React.useRef(callback);
|
||||
|
||||
ref.current = callback;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
ref.current();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useUnmount;
|
||||
+40
-29
@@ -28,40 +28,51 @@ if (env.SENTRY_DSN) {
|
||||
|
||||
if ("serviceWorker" in window.navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
window.navigator.serviceWorker
|
||||
.register("/static/service-worker.js", {
|
||||
// see: https://bugs.chromium.org/p/chromium/issues/detail?id=1097616
|
||||
// In some rare (<0.1% of cases) this call can return `undefined`
|
||||
const maybePromise = window.navigator.serviceWorker.register(
|
||||
"/static/service-worker.js",
|
||||
{
|
||||
scope: "/",
|
||||
})
|
||||
.then((registration) => {
|
||||
console.log("SW registered: ", registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log("SW registration failed: ", registrationError);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (maybePromise && maybePromise.then) {
|
||||
maybePromise
|
||||
.then((registration) => {
|
||||
console.log("SW registered: ", registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log("SW registration failed: ", registrationError);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (element) {
|
||||
render(
|
||||
<Provider {...stores}>
|
||||
<Analytics>
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
</Provider>,
|
||||
element
|
||||
const App = () => (
|
||||
<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);
|
||||
}
|
||||
|
||||
window.addEventListener("load", async () => {
|
||||
@@ -70,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");
|
||||
|
||||
@@ -18,7 +18,7 @@ import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import Guide from "components/Guide";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
@@ -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">
|
||||
@@ -90,13 +90,13 @@ function AccountMenu(props: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
<Guide
|
||||
isOpen={keyboardShortcutsOpen}
|
||||
onRequestClose={() => setKeyboardShortcutsOpen(false)}
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Modal>
|
||||
</Guide>
|
||||
<MenuButton {...menu}>{props.children}</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Account")}>
|
||||
<MenuItem {...menu} as={Link} to={settings()}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+11
-14
@@ -9,7 +9,7 @@ import Collection from "models/Collection";
|
||||
import CollectionDelete from "scenes/CollectionDelete";
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
import CollectionExport from "scenes/CollectionExport";
|
||||
import CollectionMembers from "scenes/CollectionMembers";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
@@ -42,9 +42,10 @@ function CollectionMenu({
|
||||
const history = useHistory();
|
||||
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
const [showCollectionMembers, setShowCollectionMembers] = React.useState(
|
||||
false
|
||||
);
|
||||
const [
|
||||
showCollectionPermissions,
|
||||
setShowCollectionPermissions,
|
||||
] = React.useState(false);
|
||||
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
|
||||
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
|
||||
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
|
||||
@@ -155,9 +156,9 @@ function CollectionMenu({
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Members")}…`,
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionMembers(true),
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
@@ -178,15 +179,11 @@ function CollectionMenu({
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection members")}
|
||||
onRequestClose={() => setShowCollectionMembers(false)}
|
||||
isOpen={showCollectionMembers}
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionPermissions(false)}
|
||||
isOpen={showCollectionPermissions}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={collection}
|
||||
onSubmit={() => setShowCollectionMembers(false)}
|
||||
onEdit={() => setShowCollectionEdit(true)}
|
||||
/>
|
||||
<CollectionPermissions collection={collection} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
|
||||
+15
-30
@@ -8,7 +8,7 @@ import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import DocumentShare from "scenes/DocumentShare";
|
||||
import DocumentMove from "scenes/DocumentMove";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
@@ -16,12 +16,10 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import {
|
||||
documentHistoryUrl,
|
||||
documentMoveUrl,
|
||||
documentUrl,
|
||||
editDocumentUrl,
|
||||
newDocumentUrl,
|
||||
@@ -52,7 +50,6 @@ function DocumentMenu({
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { policies, collections, ui, documents } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal,
|
||||
@@ -64,8 +61,8 @@ function DocumentMenu({
|
||||
const { t } = useTranslation();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [showMoveModal, setShowMoveModal] = React.useState(false);
|
||||
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
||||
const [showShareModal, setShowShareModal] = React.useState(false);
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
@@ -132,17 +129,8 @@ function DocumentMenu({
|
||||
[document]
|
||||
);
|
||||
|
||||
const handleShareLink = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
await document.share();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = !!(can.share && team.sharing);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
|
||||
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
@@ -289,11 +277,6 @@ function DocumentMenu({
|
||||
onClick: handleStar,
|
||||
visible: !document.isStarred && !!can.star,
|
||||
},
|
||||
{
|
||||
title: `${t("Share link")}…`,
|
||||
onClick: handleShareLink,
|
||||
visible: canShareDocuments,
|
||||
},
|
||||
{
|
||||
title: t("Enable embeds"),
|
||||
onClick: document.enableEmbeds,
|
||||
@@ -351,7 +334,7 @@ function DocumentMenu({
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
to: documentMoveUrl(document),
|
||||
onClick: () => setShowMoveModal(true),
|
||||
visible: !!can.move,
|
||||
},
|
||||
{
|
||||
@@ -379,6 +362,18 @@ function DocumentMenu({
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
@@ -401,16 +396,6 @@ function DocumentMenu({
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Share document")}
|
||||
onRequestClose={() => setShowShareModal(false)}
|
||||
isOpen={showShareModal}
|
||||
>
|
||||
<DocumentShare
|
||||
document={document}
|
||||
onSubmit={() => setShowShareModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
@@ -33,4 +32,4 @@ function MemberMenu({ onRemove }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(MemberMenu);
|
||||
export default MemberMenu;
|
||||
|
||||
@@ -12,14 +12,21 @@ import ContextMenu from "components/ContextMenu";
|
||||
import Header from "components/ContextMenu/Header";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
function NewDocumentMenu() {
|
||||
const menu = useMenuState();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const singleCollection = collections.orderedData.length === 1;
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (singleCollection) {
|
||||
return (
|
||||
|
||||
@@ -11,13 +11,20 @@ import ContextMenu from "components/ContextMenu";
|
||||
import Header from "components/ContextMenu/Header";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
function NewTemplateMenu() {
|
||||
const menu = useMenuState();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+10
-5
@@ -17,9 +17,10 @@ type Props = {
|
||||
|
||||
function ShareMenu({ share }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { ui, shares } = useStores();
|
||||
const { ui, shares, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const can = policies.abilities(share.id);
|
||||
|
||||
const handleGoToDocument = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
@@ -57,10 +58,14 @@ function ShareMenu({ share }: Props) {
|
||||
<MenuItem {...menu} onClick={handleGoToDocument}>
|
||||
{t("Go to document")}
|
||||
</MenuItem>
|
||||
<hr />
|
||||
<MenuItem {...menu} onClick={handleRevoke}>
|
||||
{t("Revoke link")}
|
||||
</MenuItem>
|
||||
{can.revoke && (
|
||||
<>
|
||||
<hr />
|
||||
<MenuItem {...menu} onClick={handleRevoke}>
|
||||
{t("Revoke link")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
+33
-6
@@ -37,7 +37,7 @@ function UserMenu({ user }: Props) {
|
||||
[users, user, t]
|
||||
);
|
||||
|
||||
const handleDemote = React.useCallback(
|
||||
const handleMember = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
if (
|
||||
@@ -49,7 +49,27 @@ function UserMenu({ user }: Props) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.demote(user);
|
||||
users.demote(user, "Member");
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
|
||||
const handleViewer = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content",
|
||||
{
|
||||
userName: user.name,
|
||||
}
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.demote(user, "Viewer");
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
@@ -95,18 +115,25 @@ function UserMenu({ user }: Props) {
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Make {{ userName }} a member…", {
|
||||
title: t("Make {{ userName }} a member", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handleDemote,
|
||||
visible: can.demote,
|
||||
onClick: handleMember,
|
||||
visible: can.demote && user.rank !== "Member",
|
||||
},
|
||||
{
|
||||
title: t("Make {{ userName }} a viewer", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handleViewer,
|
||||
visible: can.demote && user.rank !== "Viewer",
|
||||
},
|
||||
{
|
||||
title: t("Make {{ userName }} an admin…", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handlePromote,
|
||||
visible: can.promote,
|
||||
visible: can.promote && user.rank !== "Admin",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class Collection extends BaseModel {
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
private: boolean;
|
||||
permission: "read" | "read_write" | void;
|
||||
sharing: boolean;
|
||||
index: string;
|
||||
documents: NavigationNode[];
|
||||
@@ -25,11 +25,6 @@ export default class Collection extends BaseModel {
|
||||
sort: { field: string, direction: "asc" | "desc" };
|
||||
url: string;
|
||||
|
||||
@computed
|
||||
get isPrivate(): boolean {
|
||||
return this.private;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isEmpty(): boolean {
|
||||
return this.documents.length === 0;
|
||||
@@ -121,7 +116,7 @@ export default class Collection extends BaseModel {
|
||||
"description",
|
||||
"sharing",
|
||||
"icon",
|
||||
"private",
|
||||
"permission",
|
||||
"sort",
|
||||
"index",
|
||||
]);
|
||||
|
||||
@@ -24,7 +24,7 @@ export default class Document extends BaseModel {
|
||||
@observable lastViewedAt: ?string;
|
||||
store: DocumentsStore;
|
||||
|
||||
collaborators: User[];
|
||||
collaboratorIds: string[];
|
||||
collectionId: string;
|
||||
createdAt: string;
|
||||
createdBy: User;
|
||||
|
||||
@@ -9,6 +9,8 @@ class Share extends BaseModel {
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
documentUrl: string;
|
||||
lastAccessedAt: ?string;
|
||||
includeChildDocuments: boolean;
|
||||
createdBy: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import { computed } from "mobx";
|
||||
import type { Rank } from "shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
|
||||
class User extends BaseModel {
|
||||
@@ -8,6 +9,7 @@ class User extends BaseModel {
|
||||
name: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
isViewer: boolean;
|
||||
lastActiveAt: string;
|
||||
isSuspended: boolean;
|
||||
createdAt: string;
|
||||
@@ -17,6 +19,17 @@ class User extends BaseModel {
|
||||
get isInvited(): boolean {
|
||||
return !this.lastActiveAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get rank(): Rank {
|
||||
if (this.isAdmin) {
|
||||
return "Admin";
|
||||
} else if (this.isViewer) {
|
||||
return "Viewer";
|
||||
} else {
|
||||
return "Member";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -20,7 +20,9 @@ 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 NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
|
||||
+24
-4
@@ -4,11 +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 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 (
|
||||
@@ -22,7 +36,13 @@ export default function Routes() {
|
||||
<Switch>
|
||||
<Route exact path="/" component={Login} />
|
||||
<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,12 +1,12 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch } from "react-router-dom";
|
||||
import Settings from "scenes/Settings";
|
||||
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";
|
||||
import Notifications from "scenes/Settings/Notifications";
|
||||
import People from "scenes/Settings/People";
|
||||
import Profile from "scenes/Settings/Profile";
|
||||
import Security from "scenes/Settings/Security";
|
||||
import Shares from "scenes/Settings/Shares";
|
||||
import Slack from "scenes/Settings/Slack";
|
||||
@@ -17,11 +17,10 @@ import Route from "components/ProfiledRoute";
|
||||
export default function SettingsRoutes() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+341
-314
@@ -1,19 +1,12 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
import CollectionMembers from "scenes/CollectionMembers";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import Search from "scenes/Search";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
import Badge from "components/Badge";
|
||||
@@ -25,7 +18,8 @@ import DocumentList from "components/DocumentList";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import InputSearchPage from "components/InputSearchPage";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Mask from "components/Mask";
|
||||
import Modal from "components/Modal";
|
||||
@@ -35,109 +29,105 @@ import Subheading from "components/Subheading";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
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 { AuthorizationError } from "utils/errors";
|
||||
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
match: Match,
|
||||
t: TFunction,
|
||||
};
|
||||
function CollectionScene() {
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies, collections, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const [isFetching, setFetching] = React.useState();
|
||||
const [error, setError] = React.useState();
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
|
||||
|
||||
@observer
|
||||
class CollectionScene extends React.Component<Props> {
|
||||
@observable collection: ?Collection;
|
||||
@observable isFetching: boolean = true;
|
||||
@observable permissionsModalOpen: boolean = false;
|
||||
@observable editModalOpen: boolean = false;
|
||||
const collectionId = params.id || "";
|
||||
const collection = collections.get(collectionId);
|
||||
const can = policies.abilities(collectionId || "");
|
||||
const canUser = policies.abilities(team.id);
|
||||
const { handleFiles, isImporting } = useImportDocument(collectionId);
|
||||
|
||||
componentDidMount() {
|
||||
const { id } = this.props.match.params;
|
||||
if (id) {
|
||||
this.loadContent(id);
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
ui.setActiveCollection(collection);
|
||||
}
|
||||
}
|
||||
}, [ui, collection]);
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const { id } = this.props.match.params;
|
||||
React.useEffect(() => {
|
||||
setError(null);
|
||||
documents.fetchPinned({ collectionId });
|
||||
}, [documents, collectionId]);
|
||||
|
||||
if (this.collection) {
|
||||
const { collection } = this;
|
||||
const policy = this.props.policies.get(collection.id);
|
||||
|
||||
if (!policy) {
|
||||
this.loadContent(collection.id);
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
if ((!can || !collection) && !error && !isFetching) {
|
||||
try {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
await collections.fetch(collectionId);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [collections, isFetching, collection, error, collectionId, can]);
|
||||
|
||||
if (id && id !== prevProps.match.params.id) {
|
||||
this.loadContent(id);
|
||||
}
|
||||
useUnmount(ui.clearActiveCollection);
|
||||
|
||||
const handlePermissionsModalOpen = React.useCallback(() => {
|
||||
setPermissionsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePermissionsModalClose = React.useCallback(() => {
|
||||
setPermissionsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleRejection = React.useCallback(() => {
|
||||
ui.showToast(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
|
||||
{ type: "error" }
|
||||
);
|
||||
}, [t, ui]);
|
||||
|
||||
if (!collection && error) {
|
||||
return <Search notFound />;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.ui.clearActiveCollection();
|
||||
}
|
||||
const pinnedDocuments = collection
|
||||
? documents.pinnedInCollection(collection.id)
|
||||
: [];
|
||||
const collectionName = collection ? collection.name : "";
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
|
||||
loadContent = async (id: string) => {
|
||||
try {
|
||||
const collection = await this.props.collections.fetch(id);
|
||||
|
||||
if (collection) {
|
||||
this.props.ui.setActiveCollection(collection);
|
||||
this.collection = collection;
|
||||
|
||||
await this.props.documents.fetchPinned({
|
||||
collectionId: id,
|
||||
});
|
||||
return collection ? (
|
||||
<Scene
|
||||
centered={false}
|
||||
textTitle={collection.name}
|
||||
title={
|
||||
<>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
{collection.name}
|
||||
</>
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
this.collection = null;
|
||||
}
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
onPermissions = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.permissionsModalOpen = true;
|
||||
};
|
||||
|
||||
handlePermissionsModalClose = () => {
|
||||
this.permissionsModalOpen = false;
|
||||
};
|
||||
|
||||
handleEditModalOpen = () => {
|
||||
this.editModalOpen = true;
|
||||
};
|
||||
|
||||
handleEditModalClose = () => {
|
||||
this.editModalOpen = false;
|
||||
};
|
||||
|
||||
renderActions() {
|
||||
const { match, policies, t } = this.props;
|
||||
const can = policies.abilities(match.params.id || "");
|
||||
|
||||
return (
|
||||
<>
|
||||
{can.update && (
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
labelHidden
|
||||
collectionId={match.params.id}
|
||||
/>
|
||||
</Action>
|
||||
actions={
|
||||
<>
|
||||
<Action>
|
||||
<InputSearchPage
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
collectionId={collectionId}
|
||||
/>
|
||||
</Action>
|
||||
{can.update && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
@@ -147,228 +137,267 @@ class CollectionScene extends React.Component<Props> {
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={this.collection ? newDocumentUrl(this.collection.id) : ""}
|
||||
disabled={!this.collection}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<Action>
|
||||
<CollectionMenu
|
||||
collection={this.collection}
|
||||
placement="bottom-end"
|
||||
modal={false}
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { documents, t } = this.props;
|
||||
|
||||
if (!this.isFetching && !this.collection) return <Search notFound />;
|
||||
|
||||
const pinnedDocuments = this.collection
|
||||
? documents.pinnedInCollection(this.collection.id)
|
||||
: [];
|
||||
const collection = this.collection;
|
||||
const collectionName = collection ? collection.name : "";
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
|
||||
return collection ? (
|
||||
<Scene
|
||||
textTitle={collection.name}
|
||||
title={
|
||||
<>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
{collection.name}
|
||||
</>
|
||||
}
|
||||
actions={this.renderActions()}
|
||||
)}
|
||||
<Separator />
|
||||
<Action>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
placement="bottom-end"
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Dropzone
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={handleFiles}
|
||||
onDropRejected={handleRejection}
|
||||
disabled={!can.update}
|
||||
noClick
|
||||
multiple
|
||||
>
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
{({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
isDragActive={isDragActive}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isImporting && <LoadingIndicator />}
|
||||
|
||||
<CenteredContent withStickyHeader>
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
documents yet."
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
</HelpText>
|
||||
<Empty>
|
||||
<Link to={newDocumentUrl(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color="currentColor" />}>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{collection.private && (
|
||||
<Button onClick={this.onPermissions} neutral>
|
||||
{t("Manage members")}…
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
<Modal
|
||||
title={t("Collection members")}
|
||||
onRequestClose={this.handlePermissionsModalClose}
|
||||
isOpen={this.permissionsModalOpen}
|
||||
>
|
||||
<CollectionMembers
|
||||
collection={this.collection}
|
||||
onSubmit={this.handlePermissionsModalClose}
|
||||
onEdit={this.handleEditModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
onRequestClose={this.handleEditModalClose}
|
||||
isOpen={this.editModalOpen}
|
||||
>
|
||||
<CollectionEdit
|
||||
collection={this.collection}
|
||||
onSubmit={this.handleEditModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Centered>
|
||||
) : (
|
||||
<>
|
||||
<Heading>
|
||||
<CollectionIcon collection={collection} size={40} expanded />{" "}
|
||||
{collection.name}{" "}
|
||||
{collection.private && (
|
||||
<Tooltip
|
||||
tooltip={t(
|
||||
"This collection is only visible to people given access"
|
||||
)}
|
||||
placement="bottom"
|
||||
>
|
||||
<Badge>{t("Private")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Heading>
|
||||
<CollectionDescription collection={collection} />
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
{canUser.createDocument && (
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
)}
|
||||
</HelpText>
|
||||
<Empty>
|
||||
{canUser.createDocument && (
|
||||
<Link to={newDocumentUrl(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color="currentColor" />}>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button onClick={handlePermissionsModalOpen} neutral>
|
||||
{t("Manage permissions")}…
|
||||
</Button>
|
||||
</Empty>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={handlePermissionsModalClose}
|
||||
isOpen={permissionsModalOpen}
|
||||
>
|
||||
<CollectionPermissions collection={collection} />
|
||||
</Modal>
|
||||
</Centered>
|
||||
) : (
|
||||
<>
|
||||
<Heading>
|
||||
<CollectionIcon
|
||||
collection={collection}
|
||||
size={40}
|
||||
expanded
|
||||
/>{" "}
|
||||
{collection.name}{" "}
|
||||
{!collection.permission && (
|
||||
<Tooltip
|
||||
tooltip={t(
|
||||
"This collection is only visible to those given access"
|
||||
)}
|
||||
placement="bottom"
|
||||
>
|
||||
<Badge>{t("Private")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Heading>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
<>
|
||||
<Subheading sticky>
|
||||
<TinyPinIcon size={18} /> {t("Pinned")}
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} showPin />
|
||||
</>
|
||||
)}
|
||||
{hasPinnedDocuments && (
|
||||
<>
|
||||
<Subheading sticky>
|
||||
<TinyPinIcon size={18} color="currentColor" />{" "}
|
||||
{t("Pinned")}
|
||||
</Subheading>
|
||||
<DocumentList documents={pinnedDocuments} showPin />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(collection.id)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Redirect to={collectionUrl(collection.id, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPublished
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab
|
||||
to={collectionUrl(collection.id, "alphabetical")}
|
||||
exact
|
||||
>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Redirect
|
||||
to={collectionUrl(collection.id, "published")}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPublished
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{ collectionId: collection.id }}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
<DropMessage>{t("Drop documents to import")}</DropMessage>
|
||||
</CenteredContent>
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Scene>
|
||||
) : (
|
||||
<CenteredContent>
|
||||
<Heading>
|
||||
<Mask height={35} />
|
||||
</Heading>
|
||||
<ListPlaceholder count={5} />
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
</Dropzone>
|
||||
</Scene>
|
||||
) : (
|
||||
<CenteredContent>
|
||||
<Heading>
|
||||
<Mask height={35} />
|
||||
</Heading>
|
||||
<ListPlaceholder count={5} />
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
const DropMessage = styled(HelpText)`
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const DropzoneContainer = styled.div`
|
||||
min-height: calc(100% - 56px);
|
||||
position: relative;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
css`
|
||||
&:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
background: ${theme.background};
|
||||
border-radius: 8px;
|
||||
border: 1px dashed ${theme.divider};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
${DropMessage} {
|
||||
opacity: 1;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
text-align: center;
|
||||
margin: 40vh auto 0;
|
||||
@@ -387,6 +416,4 @@ const Empty = styled(Flex)`
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
export default withTranslation()<CollectionScene>(
|
||||
inject("collections", "policies", "documents", "ui")(CollectionScene)
|
||||
);
|
||||
export default observer(CollectionScene);
|
||||
|
||||
@@ -28,7 +28,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
@observable sharing: boolean = this.props.collection.sharing;
|
||||
@observable icon: string = this.props.collection.icon;
|
||||
@observable color: string = this.props.collection.color || "#4E5C6E";
|
||||
@observable private: boolean = this.props.collection.private;
|
||||
@observable sort: { field: string, direction: "asc" | "desc" } = this.props
|
||||
.collection.sort;
|
||||
@observable isSaving: boolean;
|
||||
@@ -43,7 +42,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
name: this.name,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
sharing: this.sharing,
|
||||
sort: this.sort,
|
||||
});
|
||||
@@ -75,10 +73,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
this.icon = icon;
|
||||
};
|
||||
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.private = ev.target.checked;
|
||||
};
|
||||
|
||||
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
|
||||
this.sharing = ev.target.checked;
|
||||
};
|
||||
@@ -122,17 +116,6 @@ class CollectionEdit extends React.Component<Props> {
|
||||
value={`${this.sort.field}.${this.sort.direction}`}
|
||||
onChange={this.handleSortChange}
|
||||
/>
|
||||
<Switch
|
||||
id="private"
|
||||
label={t("Private collection")}
|
||||
onChange={this.handlePrivateChange}
|
||||
checked={this.private}
|
||||
/>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
A private collection will only be visible to invited team members.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
|
||||
import GroupsStore from "stores/GroupsStore";
|
||||
import MembershipsStore from "stores/MembershipsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import Collection from "models/Collection";
|
||||
import Button from "components/Button";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import Subheading from "components/Subheading";
|
||||
import AddGroupsToCollection from "./AddGroupsToCollection";
|
||||
import AddPeopleToCollection from "./AddPeopleToCollection";
|
||||
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
|
||||
import MemberListItem from "./components/MemberListItem";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
users: UsersStore,
|
||||
memberships: MembershipsStore,
|
||||
collectionGroupMemberships: CollectionGroupMembershipsStore,
|
||||
groups: GroupsStore,
|
||||
onEdit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class CollectionMembers extends React.Component<Props> {
|
||||
@observable addGroupModalOpen: boolean = false;
|
||||
@observable addMemberModalOpen: boolean = false;
|
||||
|
||||
handleAddGroupModalOpen = () => {
|
||||
this.addGroupModalOpen = true;
|
||||
};
|
||||
|
||||
handleAddGroupModalClose = () => {
|
||||
this.addGroupModalOpen = false;
|
||||
};
|
||||
|
||||
handleAddMemberModalOpen = () => {
|
||||
this.addMemberModalOpen = true;
|
||||
};
|
||||
|
||||
handleAddMemberModalClose = () => {
|
||||
this.addMemberModalOpen = false;
|
||||
};
|
||||
|
||||
handleRemoveUser = (user) => {
|
||||
try {
|
||||
this.props.memberships.delete({
|
||||
collectionId: this.props.collection.id,
|
||||
userId: user.id,
|
||||
});
|
||||
this.props.ui.showToast(`${user.name} was removed from the collection`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not remove user", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
handleUpdateUser = (user, permission) => {
|
||||
try {
|
||||
this.props.memberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
userId: user.id,
|
||||
permission,
|
||||
});
|
||||
this.props.ui.showToast(`${user.name} permissions were updated`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not update user", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveGroup = (group) => {
|
||||
try {
|
||||
this.props.collectionGroupMemberships.delete({
|
||||
collectionId: this.props.collection.id,
|
||||
groupId: group.id,
|
||||
});
|
||||
this.props.ui.showToast(`${group.name} was removed from the collection`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not remove group", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
handleUpdateGroup = (group, permission) => {
|
||||
try {
|
||||
this.props.collectionGroupMemberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
groupId: group.id,
|
||||
permission,
|
||||
});
|
||||
this.props.ui.showToast(`${group.name} permissions were updated`, {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Could not update user", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
users,
|
||||
groups,
|
||||
memberships,
|
||||
collectionGroupMemberships,
|
||||
auth,
|
||||
} = this.props;
|
||||
const { user } = auth;
|
||||
if (!user) return null;
|
||||
|
||||
const key = memberships.orderedData
|
||||
.map((m) => m.permission)
|
||||
.concat(collection.private)
|
||||
.join("-");
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{collection.private ? (
|
||||
<>
|
||||
<HelpText>
|
||||
Choose which groups and team members have access to view and edit
|
||||
documents in the private <strong>{collection.name}</strong>{" "}
|
||||
collection. You can make this collection visible to the entire
|
||||
team by{" "}
|
||||
<ButtonLink onClick={this.props.onEdit}>
|
||||
changing the visibility
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={this.handleAddGroupModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
Add groups
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<HelpText>
|
||||
The <strong>{collection.name}</strong> collection is accessible by
|
||||
everyone on the team. If you want to limit who can view the
|
||||
collection,{" "}
|
||||
<ButtonLink onClick={this.props.onEdit}>make it private</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
{collection.private && (
|
||||
<GroupsWrap>
|
||||
<Subheading>Groups</Subheading>
|
||||
<PaginatedList
|
||||
key={key}
|
||||
items={groups.inCollection(collection.id)}
|
||||
fetch={collectionGroupMemberships.fetchPage}
|
||||
options={collection.private ? { id: collection.id } : undefined}
|
||||
empty={<Empty>This collection has no groups.</Empty>}
|
||||
renderItem={(group) => (
|
||||
<CollectionGroupMemberListItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
collectionGroupMembership={collectionGroupMemberships.get(
|
||||
`${group.id}-${collection.id}`
|
||||
)}
|
||||
onRemove={() => this.handleRemoveGroup(group)}
|
||||
onUpdate={(permission) =>
|
||||
this.handleUpdateGroup(group, permission)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={`Add groups to ${collection.name}`}
|
||||
onRequestClose={this.handleAddGroupModalClose}
|
||||
isOpen={this.addGroupModalOpen}
|
||||
>
|
||||
<AddGroupsToCollection
|
||||
collection={collection}
|
||||
onSubmit={this.handleAddGroupModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</GroupsWrap>
|
||||
)}
|
||||
{collection.private ? (
|
||||
<>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={this.handleAddMemberModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
Add individual members
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
<Subheading>Individual Members</Subheading>
|
||||
</>
|
||||
) : (
|
||||
<Subheading>Members</Subheading>
|
||||
)}
|
||||
<PaginatedList
|
||||
key={key}
|
||||
items={
|
||||
collection.private
|
||||
? users.inCollection(collection.id)
|
||||
: users.active
|
||||
}
|
||||
fetch={collection.private ? memberships.fetchPage : users.fetchPage}
|
||||
options={collection.private ? { id: collection.id } : undefined}
|
||||
renderItem={(item) => (
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
membership={memberships.get(`${item.id}-${collection.id}`)}
|
||||
canEdit={collection.private && item.id !== user.id}
|
||||
onRemove={() => this.handleRemoveUser(item)}
|
||||
onUpdate={(permission) => this.handleUpdateUser(item, permission)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={`Add people to ${collection.name}`}
|
||||
onRequestClose={this.handleAddMemberModalClose}
|
||||
isOpen={this.addMemberModalOpen}
|
||||
>
|
||||
<AddPeopleToCollection
|
||||
collection={collection}
|
||||
onSubmit={this.handleAddMemberModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const GroupsWrap = styled.div`
|
||||
margin-bottom: 50px;
|
||||
`;
|
||||
|
||||
export default inject(
|
||||
"auth",
|
||||
"users",
|
||||
"memberships",
|
||||
"collectionGroupMemberships",
|
||||
"groups",
|
||||
"ui"
|
||||
)(CollectionMembers);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import CollectionMembers from "./CollectionMembers";
|
||||
export default CollectionMembers;
|
||||
+12
-10
@@ -14,6 +14,7 @@ import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import IconPicker, { icons } from "components/IconPicker";
|
||||
import Input from "components/Input";
|
||||
import InputSelectPermission from "components/InputSelectPermission";
|
||||
import Switch from "components/Switch";
|
||||
|
||||
type Props = {
|
||||
@@ -31,7 +32,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
@observable icon: string = "";
|
||||
@observable color: string = "#4E5C6E";
|
||||
@observable sharing: boolean = true;
|
||||
@observable private: boolean = false;
|
||||
@observable permission: string = "read_write";
|
||||
@observable isSaving: boolean;
|
||||
hasOpenedIconPicker: boolean = false;
|
||||
|
||||
@@ -44,7 +45,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
sharing: this.sharing,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
private: this.private,
|
||||
permission: this.permission,
|
||||
},
|
||||
this.props.collections
|
||||
);
|
||||
@@ -87,8 +88,8 @@ class CollectionNew extends React.Component<Props> {
|
||||
this.hasOpenedIconPicker = true;
|
||||
};
|
||||
|
||||
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.private = ev.target.checked;
|
||||
handlePermissionChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.permission = ev.target.value;
|
||||
};
|
||||
|
||||
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
@@ -131,15 +132,16 @@ class CollectionNew extends React.Component<Props> {
|
||||
icon={this.icon}
|
||||
/>
|
||||
</Flex>
|
||||
<Switch
|
||||
id="private"
|
||||
label={t("Private collection")}
|
||||
onChange={this.handlePrivateChange}
|
||||
checked={this.private}
|
||||
<InputSelectPermission
|
||||
value={this.permission}
|
||||
onChange={this.handlePermissionChange}
|
||||
short
|
||||
/>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
A private collection will only be visible to invited team members.
|
||||
This is the default level of access given to team members, you can
|
||||
give specific users or groups more access once the collection is
|
||||
created.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
{teamSharingEnabled && (
|
||||
|
||||
+23
-18
@@ -8,14 +8,14 @@ import GroupListItem from "components/GroupListItem";
|
||||
import InputSelect from "components/InputSelect";
|
||||
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
group: Group,
|
||||
collectionGroupMembership: ?CollectionGroupMembership,
|
||||
onUpdate: (permission: string) => void,
|
||||
onRemove: () => void,
|
||||
};
|
||||
onUpdate: (permission: string) => any,
|
||||
onRemove: () => any,
|
||||
|};
|
||||
|
||||
const MemberListItem = ({
|
||||
const CollectionGroupMemberListItem = ({
|
||||
group,
|
||||
collectionGroupMembership,
|
||||
onUpdate,
|
||||
@@ -25,8 +25,8 @@ const MemberListItem = ({
|
||||
|
||||
const PERMISSIONS = React.useMemo(
|
||||
() => [
|
||||
{ label: t("Read only"), value: "read" },
|
||||
{ label: t("Read & Edit"), value: "read_write" },
|
||||
{ label: t("View only"), value: "read" },
|
||||
{ label: t("View and edit"), value: "read_write" },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
@@ -36,6 +36,7 @@ const MemberListItem = ({
|
||||
group={group}
|
||||
onRemove={onRemove}
|
||||
onUpdate={onUpdate}
|
||||
showAvatar
|
||||
renderActions={({ openMembersModal }) => (
|
||||
<>
|
||||
<Select
|
||||
@@ -49,25 +50,29 @@ const MemberListItem = ({
|
||||
onChange={(ev) => onUpdate(ev.target.value)}
|
||||
labelHidden
|
||||
/>
|
||||
<ButtonWrap>
|
||||
<CollectionGroupMemberMenu
|
||||
onMembers={openMembersModal}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ButtonWrap>
|
||||
<Spacer />
|
||||
<CollectionGroupMemberMenu
|
||||
onMembers={openMembersModal}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
width: 8px;
|
||||
`;
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonWrap = styled.div`
|
||||
margin-left: 6px;
|
||||
`;
|
||||
|
||||
export default MemberListItem;
|
||||
export default CollectionGroupMemberListItem;
|
||||
+27
-13
@@ -17,9 +17,9 @@ type Props = {
|
||||
user: User,
|
||||
membership?: ?Membership,
|
||||
canEdit: boolean,
|
||||
onAdd?: () => void,
|
||||
onRemove?: () => void,
|
||||
onUpdate?: (permission: string) => void,
|
||||
onAdd?: () => any,
|
||||
onRemove?: () => any,
|
||||
onUpdate?: (permission: string) => any,
|
||||
};
|
||||
|
||||
const MemberListItem = ({
|
||||
@@ -34,8 +34,8 @@ const MemberListItem = ({
|
||||
|
||||
const PERMISSIONS = React.useMemo(
|
||||
() => [
|
||||
{ label: t("Read only"), value: "read" },
|
||||
{ label: t("Read & Edit"), value: "read_write" },
|
||||
{ label: t("View only"), value: "read" },
|
||||
{ label: t("View and edit"), value: "read_write" },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
@@ -56,24 +56,29 @@ const MemberListItem = ({
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{canEdit && onUpdate && (
|
||||
{onUpdate && (
|
||||
<Select
|
||||
label={t("Permissions")}
|
||||
options={PERMISSIONS}
|
||||
value={membership ? membership.permission : undefined}
|
||||
onChange={(ev) => onUpdate(ev.target.value)}
|
||||
disabled={!canEdit}
|
||||
labelHidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEdit && onRemove && <MemberMenu onRemove={onRemove} />}
|
||||
{canEdit && onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Spacer />
|
||||
{onRemove && <MemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
@@ -81,9 +86,18 @@ const MemberListItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
width: 8px;
|
||||
`;
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default MemberListItem;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user