Compare commits

..

3 Commits

Author SHA1 Message Date
Tom Moor 8f01499c9c fix: Janky route jump when accessing Document -> Move from non-document scene 2021-03-30 08:16:31 -07:00
Tom Moor b382467a04 tweak 2021-03-30 00:31:19 -07:00
Tom Moor 7091176c33 chore: Fix modal nesting, remove react-modal 2021-03-29 23:34:12 -07:00
401 changed files with 7880 additions and 19805 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"@babel/preset-env",
{
"corejs": {
"version": "3",
"version": "2",
"proposals": true
},
"useBuiltIns": "usage"
+25 -47
View File
@@ -8,20 +8,18 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
SECRET_KEY=generate_a_new_key
# 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.
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
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
@@ -29,29 +27,8 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# 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.
# 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 configure Slack auth, you'll need to create an Application at
# => https://api.slack.com/apps
@@ -69,12 +46,6 @@ 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=
@@ -94,17 +65,9 @@ FORCE_HTTPS=true
# the maintainers
ENABLE_UPDATES=true
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# 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,http,server,services
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
@@ -124,6 +87,23 @@ 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=
@@ -132,12 +112,10 @@ SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
DEFAULT_LANGUAGE=en_US
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [outline]
+2 -3
View File
@@ -1,13 +1,12 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 90
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 14
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- security
- pinned
# Label to use when marking an issue as stale
staleLabel: stale
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.55.0
Licensed Work: Outline 0.51.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: 2024-04-22
Change Date: 2023-12-13
Change License: Apache License, Version 2.0
+4 -5
View File
@@ -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`
@@ -96,8 +96,7 @@ For contributing features and fixes you can quickly get an environment running u
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
-9
View File
@@ -135,15 +135,6 @@
"description": "wikireply@example.com (optional)",
"required": false
},
"SMTP_SECURE": {
"value": "true",
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "UA-xxxx (optional)",
"required": false
-30
View File
@@ -1,30 +0,0 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.js"
]
}
+1 -1
View File
@@ -33,7 +33,7 @@ const Actions = styled(Flex)`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
padding: 12px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
@media print {
display: none;
+1 -3
View File
@@ -9,9 +9,7 @@ type Props = {
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return null;
}
if (!env.GOOGLE_ANALYTICS_ID) return;
// standard Google Analytics script
window.ga =
-44
View File
@@ -1,44 +0,0 @@
// @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;
+4 -31
View File
@@ -1,46 +1,19 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import SlackLogo from "../SlackLogo";
import GoogleLogo from "./GoogleLogo";
import MicrosoftLogo from "./MicrosoftLogo";
import SlackLogo from "./SlackLogo";
type Props = {|
providerName: string,
size?: number,
|};
function AuthLogo({ providerName, size = 16 }: Props) {
export default function AuthLogo({ providerName }: Props) {
switch (providerName) {
case "slack":
return (
<Logo>
<SlackLogo size={size} />
</Logo>
);
return <SlackLogo size={16} />;
case "google":
return (
<Logo>
<GoogleLogo size={size} />
</Logo>
);
case "azure":
return (
<Logo>
<MicrosoftLogo size={size} />
</Logo>
);
return <GoogleLogo size={16} />;
default:
return null;
}
}
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`;
export default AuthLogo;
+5 -2
View File
@@ -6,7 +6,6 @@ import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import { changeLanguage } from "../utils/language";
import env from "env";
type Props = {
@@ -21,7 +20,11 @@ const Authenticated = ({ children }: Props) => {
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
changeLanguage(language, i18n);
if (language && i18n.language !== language) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(language.replace("_", "-"));
}
}, [i18n, language]);
if (auth.authenticated) {
+16 -14
View File
@@ -1,4 +1,5 @@
// @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";
@@ -15,7 +16,7 @@ type Props = {
isPresent: boolean,
isEditing: boolean,
isCurrentUser: boolean,
profileOnClick: boolean,
lastViewedAt: string,
t: TFunction,
};
@@ -32,13 +33,22 @@ class AvatarWithPresence extends React.Component<Props> {
};
render() {
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
const {
user,
lastViewedAt,
isPresent,
isEditing,
isCurrentUser,
t,
} = this.props;
const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
: t("viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
});
return (
<>
@@ -46,12 +56,8 @@ class AvatarWithPresence extends React.Component<Props> {
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{action && (
<>
<br />
{action}
</>
)}
<br />
{action}
</Centered>
}
placement="bottom"
@@ -59,11 +65,7 @@ class AvatarWithPresence extends React.Component<Props> {
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? undefined
: this.handleOpenProfile
}
onClick={this.handleOpenProfile}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
+168 -51
View File
@@ -1,87 +1,204 @@
// @flow
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
} 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";
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
items: MenuItem[],
max?: number,
document: Document,
children?: React.Node,
highlightFirstItem?: boolean,
onlyText: boolean,
|};
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
let topLevelItems: MenuItem[] = [...items];
let overflowItems;
function Icon({ document }) {
const { t } = useTranslation();
// 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} />,
});
if (document.isDeleted) {
return (
<>
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
if (!collections.isLoaded) {
return;
}
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">
{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>
))}
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
<span>{collection.name}</span>
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
<>
<Slash />{" "}
<Crumb to={lastPath.url} title={lastPath.title}>
{lastPath.title}
</Crumb>
</>
)}
{children}
</Flex>
);
}
};
const Slash = styled(GoToIcon)`
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Item = styled(Link)`
display: flex;
flex-shrink: 1;
min-width: 0;
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)`
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;
}
`;
export default Breadcrumb;
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);
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "styles/animations";
import { bounceIn } from "shared/styles/animations";
type Props = {|
count: number,
+25 -27
View File
@@ -134,32 +134,30 @@ export type Props = {|
"data-event-action"?: string,
|};
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;
function Button({
type = "text",
icon,
children,
value,
disclosure,
innerRef,
neutral,
...rest
}: Props) {
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 Button;
export default React.forwardRef<Props, typeof Button>((props, ref) => (
<Button {...props} innerRef={ref} />
));
-4
View File
@@ -21,10 +21,6 @@ const Container = styled.div`
const Content = styled.div`
max-width: 46em;
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: 52em;
`};
`;
const CenteredContent = ({ children, ...rest }: Props) => {
-78
View File
@@ -1,78 +0,0 @@
// @flow
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage) => {
const tooLow = !Number.isFinite(+percentage) || percentage < 0;
const tooHigh = percentage > 100;
return tooLow ? 0 : tooHigh ? 100 : +percentage;
};
const Circle = ({
color,
percentage,
offset,
}: {
color: string,
percentage?: number,
offset: number,
}) => {
const radius = offset * 0.7;
const circumference = 2 * Math.PI * radius;
let strokePercentage;
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
}
return (
<circle
r={radius}
cx={offset}
cy={offset}
fill="none"
stroke={strokePercentage !== circumference ? color : ""}
strokeWidth={2.5}
strokeDasharray={circumference}
strokeDashoffset={percentage ? strokePercentage : 0}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.6s ease 0s" }}
></circle>
);
};
const CircularProgressBar = ({
percentage,
size = 16,
}: {
percentage: number,
size?: number,
}) => {
const theme = useTheme();
percentage = cleanPercentage(percentage);
const offset = Math.floor(size / 2);
return (
<SVG width={size} height={size}>
<g transform={`rotate(-90 ${offset} ${offset})`}>
<Circle color={theme.progressBarBackground} offset={offset} />
{percentage > 0 && (
<Circle
color={theme.primary}
percentage={percentage}
offset={offset}
/>
)}
</g>
</SVG>
);
};
const SVG = styled.svg`
flex-shrink: 0;
`;
export default CircularProgressBar;
+59 -83
View File
@@ -1,103 +1,79 @@
// @flow
import { sortBy, filter, uniq } from "lodash";
import { observer } from "mobx-react";
import { sortBy, keyBy } from "lodash";
import { observer, inject } 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 = {|
type Props = {
views: ViewsStore,
presence: DocumentPresenceStore,
document: Document,
currentUserId: string,
|};
};
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;
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
if (!this.props.document.isDeleted) {
this.props.views.fetchPage({ documentId: this.props.document.id });
}
}
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
if (!users.get(userId)) {
return users.fetch(userId);
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);
}
});
}, [document, users, presentIds, document.collaboratorIds]);
);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
const overflow = documentViews.length - mostRecentViewers.length;
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 (
<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 (
<AvatarWithPresence
key={user.id}
user={user}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
profileOnClick={false}
/>
);
}}
return (
<AvatarWithPresence
key={user.id}
user={user}
lastViewedAt={lastViewedAt}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
/>
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
);
);
}}
/>
);
}
}
const FacepileHiddenOnMobile = styled(Facepile)`
@@ -106,4 +82,4 @@ const FacepileHiddenOnMobile = styled(Facepile)`
`};
`;
export default observer(Collaborators);
export default inject("views", "presence")(Collaborators);
+2 -4
View File
@@ -12,15 +12,13 @@ import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
|};
function CollectionDescription({ collection }: Props) {
const { collections, policies } = useStores();
const { showToast } = useToasts();
const { collections, ui, policies } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
@@ -55,7 +53,7 @@ function CollectionDescription({ collection }: Props) {
});
setDirty(false);
} catch (err) {
showToast(
ui.showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
+10 -28
View File
@@ -3,7 +3,6 @@ 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>,
@@ -15,7 +14,6 @@ type Props = {|
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
|};
const MenuItem = ({
@@ -30,26 +28,15 @@ const MenuItem = ({
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
}
if (hide) {
hide();
}
},
[onClick, hide]
[hide, onClick]
);
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const handleMouseDown = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
}, []);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
@@ -60,14 +47,12 @@ const MenuItem = ({
{(props) => (
<MenuAnchor
{...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
@@ -78,18 +63,16 @@ const MenuItem = ({
);
};
const Spacer = styled.svg`
const Spacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const MenuAnchor = styled.a`
display: flex;
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
padding: 6px 12px;
width: 100%;
min-height: 32px;
background: none;
@@ -97,7 +80,7 @@ export const MenuAnchor = styled.a`
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
font-size: 15px;
cursor: default;
user-select: none;
@@ -115,8 +98,7 @@ export const MenuAnchor = styled.a`
? "pointer-events: none;"
: `
&:hover,
&:focus,
&:hover,
&.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
@@ -127,11 +109,11 @@ export const MenuAnchor = styled.a`
fill: ${props.theme.white};
}
}
`};
${breakpoint("tablet")`
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
}
`};
`;
+43 -15
View File
@@ -9,11 +9,49 @@ import {
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { type MenuItem as TMenuItem } from "types";
type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: TMenuItem[],
@@ -45,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -63,11 +101,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
return filtered.map((item, index) => {
if (item.to) {
return (
<MenuItem
@@ -90,8 +124,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
target="_blank"
{...menu}
>
{item.title}
@@ -130,11 +163,6 @@ function Template({ items, ...menu }: Props): React.Node {
return <Separator key={index} />;
}
if (item.type === "heading") {
return <Header>{item.title}</Header>;
}
console.warn("Unrecognized menu item", item);
return null;
});
}
+17 -76
View File
@@ -1,21 +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 breakpoint from "styled-components-breakpoint";
import { fadeAndScaleIn } from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "styles/animations";
type Props = {|
"aria-label": string,
visible?: boolean,
placement?: string,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
@@ -44,93 +37,41 @@ export default function ContextMenu({
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop />
</Portal>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => (
<Position {...props}>
<Background>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</>
</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: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${(props) => props.theme.menuBackground};
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"};
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: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 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"};
`};
`;
-1
View File
@@ -15,7 +15,6 @@ class CopyToClipboard extends React.PureComponent<Props> {
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
if (onCopy) onCopy();
-11
View File
@@ -1,11 +0,0 @@
// @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;
-138
View File
@@ -1,138 +0,0 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import CollectionIcon from "components/CollectionIcon";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
|};
function useCategory(document) {
const { t } = useTranslation();
if (document.isDeleted) {
return {
icon: <TrashIcon color="currentColor" />,
title: t("Trash"),
to: "/trash",
};
}
if (document.isArchived) {
return {
icon: <ArchiveIcon color="currentColor" />,
title: t("Archive"),
to: "/archive",
};
}
if (document.isDraft) {
return {
icon: <EditIcon color="currentColor" />,
title: t("Drafts"),
to: "/drafts",
};
}
if (document.isTemplate) {
return {
icon: <ShapesIcon color="currentColor" />,
title: t("Templates"),
to: "/templates",
};
}
return null;
}
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
url: "deleted-collection",
};
}
const path = React.useMemo(
() =>
collection && collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [],
[collection, document.id]
);
const items = React.useMemo(() => {
let output = [];
if (category) {
output.push(category);
}
if (collection) {
output.push({
icon: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.url),
});
}
path.forEach((p) => {
output.push({
title: p.title,
to: p.url,
});
});
return output;
}, [path, category, collection]);
if (!collections.isLoaded) {
return null;
}
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
return <Breadcrumb items={items} children={children} highlightFirstItem />;
};
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
export default observer(DocumentBreadcrumb);
@@ -15,7 +15,7 @@ import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
@@ -120,7 +120,7 @@ class DocumentHistory extends React.Component<Props> {
</Header>
{showLoading ? (
<Loading>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</Loading>
) : (
<ArrowKeyNavigation
@@ -1,5 +1,5 @@
// @flow
import { format } from "date-fns";
import format from "date-fns/format";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -37,7 +37,7 @@ class RevisionListItem extends React.Component<Props> {
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
</Time>
</Meta>
{showMenu && (
+22 -38
View File
@@ -15,10 +15,7 @@ import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
@@ -42,12 +39,10 @@ function replaceResultMarks(tag: string) {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props, ref) {
function DocumentListItem(props: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
showNestedDocuments,
@@ -65,12 +60,9 @@ function DocumentListItem(props: Props, ref) {
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
return (
<DocumentLink
ref={ref}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -79,12 +71,8 @@ function DocumentListItem(props: Props, ref) {
}}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
@@ -123,29 +111,26 @@ function DocumentListItem(props: Props, ref) {
/>
</Content>
<Actions>
{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>
&nbsp;
</>
)}
{document.isTemplate && !document.isArchived && !document.isDeleted && (
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
</Actions>
@@ -228,7 +213,6 @@ const DocumentLink = styled(Link)`
const Heading = styled.h3`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 24px;
margin-top: 0;
@@ -259,4 +243,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default observer(React.forwardRef(DocumentListItem));
export default observer(DocumentListItem);
+13 -26
View File
@@ -5,15 +5,12 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import DocumentTasks from "components/DocumentTasks";
import Breadcrumb from "components/Breadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
@@ -52,9 +49,7 @@ function DocumentMeta({
...rest
}: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
@@ -65,8 +60,6 @@ function DocumentMeta({
deletedAt,
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -75,8 +68,6 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
if (deletedAt) {
@@ -111,16 +102,14 @@ function DocumentMeta({
);
} else {
content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
<Modified highlight={modifiedSinceViewed}>
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -141,31 +130,29 @@ function DocumentMeta({
);
};
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}&nbsp;
<Container align="center" {...rest}>
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<strong>
<DocumentBreadcrumb document={document} onlyText />
<Breadcrumb document={document} onlyText />
</strong>
</span>
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp; {nestedDocumentsCount}{" "}
&nbsp;&middot; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{canShowProgressBar && (
<>
&nbsp;&nbsp;
<DocumentTasks document={document} />
</>
)}
{children}
</Container>
);
+8 -39
View File
@@ -1,69 +1,38 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import DocumentViews from "components/DocumentViews";
import Popover from "components/Popover";
import useStores from "../hooks/useStores";
type Props = {|
document: Document,
isDraft: boolean,
to?: string,
rtl?: boolean,
|};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
const { views } = useStores();
const { t } = useTranslation();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({ documentId: document.id });
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
modal: true,
});
return (
<Meta document={document} to={to} {...rest}>
<Meta document={document} to={to}>
{totalViewers && !isDraft ? (
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</a>
</>
)}
</PopoverDisclosure>
<>
&nbsp;&middot; Viewed by{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
</>
) : null}
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</Meta>
);
}
const Meta = styled(DocumentMeta)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
-59
View File
@@ -1,59 +0,0 @@
// @flow
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import CircularProgressBar from "components/CircularProgressBar";
import usePrevious from "../hooks/usePrevious";
import Document from "../models/Document";
import { bounceIn } from "styles/animations";
type Props = {|
document: Document,
|};
function getMessage(t, total, completed) {
if (completed === 0) {
return t(`{{ total }} task`, { total, count: total });
} else if (completed === total) {
return t(`{{ completed }} task done`, { completed, count: completed });
} else {
return t(`{{ completed }} of {{ total }} tasks`, {
total,
completed,
});
}
}
function DocumentTasks({ document }: Props) {
const { tasks, tasksPercentage } = document;
const { t } = useTranslation();
const theme = useTheme();
const { completed, total } = tasks;
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
<Done
color={theme.primary}
size={20}
$animated={done && previousDone === false}
/>
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
&nbsp;{message}
</>
);
}
const Done = styled(DoneIcon)`
margin: -1px;
animation: ${(props) => (props.$animated ? bounceIn : "none")} 600ms;
transform-origin: center center;
`;
export default DocumentTasks;
-80
View File
@@ -1,80 +0,0 @@
// @flow
import { formatDistanceToNow } from "date-fns";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "models/Document";
import Avatar from "components/Avatar";
import ListItem from "components/List/Item";
import PaginatedList from "components/PaginatedList";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
isOpen?: boolean,
|};
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(
documentViews,
(view) => !presentIds.includes(view.user.id)
);
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
sortedViews,
]);
return (
<>
{isOpen && (
<PaginatedList
items={users}
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: formatDistanceToNow(
view ? Date.parse(view.lastViewedAt) : new Date()
),
});
return (
<ListItem
key={item.id}
title={item.name}
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
small
/>
);
}}
/>
)}
</>
);
}
export default observer(DocumentViews);
+123
View File
@@ -0,0 +1,123 @@
// @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));
+7 -27
View File
@@ -4,21 +4,15 @@ 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/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 useToasts from "hooks/useToasts";
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(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
);
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
@@ -30,13 +24,11 @@ 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,
@@ -59,10 +51,8 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
const { id, shareId, history } = props;
const { id, ui, history } = props;
const { t } = useTranslation();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
async (file: File) => {
@@ -94,23 +84,21 @@ function Editor(props: PropsWithRef) {
}
}
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history, shareId]
[history]
);
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
if (ui) {
ui.showToast(message);
}
},
[showToast]
[ui]
);
const dictionary = React.useMemo(() => {
@@ -133,11 +121,6 @@ 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 wont work for this embed type"),
findOrCreateDoc: `${t("Find or create a doc")}`,
@@ -148,7 +131,6 @@ function Editor(props: PropsWithRef) {
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
imageCaptionPlaceholder: t("Write a caption"),
info: t("Info"),
infoNotice: t("Info notice"),
link: t("Link"),
@@ -159,7 +141,6 @@ 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 }),
@@ -189,7 +170,6 @@ function Editor(props: PropsWithRef) {
tooltip={EditorTooltip}
dictionary={dictionary}
{...props}
theme={isPrinting ? light : props.theme}
/>
</ErrorBoundary>
);
+17 -36
View File
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import styled from "styled-components";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
@@ -12,11 +11,10 @@ import PageTitle from "components/PageTitle";
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
type Props = {|
type Props = {
children: React.Node,
reloadOnChunkMissing?: boolean,
t: TFunction,
|};
};
@observer
class ErrorBoundary extends React.Component<Props> {
@@ -57,8 +55,6 @@ class ErrorBoundary extends React.Component<Props> {
};
render() {
const { t } = this.props;
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
@@ -67,21 +63,15 @@ class ErrorBoundary extends React.Component<Props> {
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title={t("Module failed to load")} />
<h1>
<Trans>Loading Failed</Trans>
</h1>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
<Trans>
Sorry, part of the application failed to load. This may be
because it was updated since you opened the tab or because of a
failed network request. Please try reloading.
</Trans>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
</HelpText>
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
@@ -89,32 +79,23 @@ class ErrorBoundary extends React.Component<Props> {
return (
<CenteredContent>
<PageTitle title={t("Something Unexpected Happened")} />
<h1>
<Trans>Something Unexpected Happened</Trans>
</h1>
<PageTitle title="Something Unexpected Happened" />
<h1>Something Unexpected Happened</h1>
<HelpText>
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
Sorry, an unrecoverable error occurred
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>{" "}
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a Bug</Trans>
Report a Bug
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show Detail</Trans>
Show Details
</Button>
)}
</p>
@@ -133,4 +114,4 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default withTranslation()<ErrorBoundary>(ErrorBoundary);
export default ErrorBoundary;
+31 -27
View File
@@ -1,41 +1,45 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import styled, { withTheme } 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,
onClick?: (event: SyntheticEvent<>) => mixed,
renderAvatar?: (user: User) => React.Node,
|};
renderAvatar: (user: User) => React.Node,
};
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>
);
@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 DefaultAvatar(user: User) {
function renderDefaultAvatar(user: User) {
return <Avatar user={user} src={user.avatarUrl} size={32} />;
}
@@ -69,4 +73,4 @@ const Avatars = styled(Flex)`
cursor: pointer;
`;
export default observer(Facepile);
export default inject("views", "presence")(withTheme(Facepile));
+1 -1
View File
@@ -1,6 +1,6 @@
// @flow
import styled from "styled-components";
import { fadeIn } from "styles/animations";
import { fadeIn } from "shared/styles/animations";
const Fade = styled.span`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
-2
View File
@@ -25,7 +25,6 @@ type Props = {|
className?: string,
children?: React.Node,
role?: string,
gap?: number,
|};
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
@@ -45,7 +44,6 @@ const Container = styled.div`
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
min-height: 0;
min-width: 0;
`;
+1 -17
View File
@@ -1,7 +1,6 @@
// @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";
@@ -18,8 +17,7 @@ type Props = {
group: Group,
groupMemberships: GroupMembershipsStore,
membership?: CollectionGroupMembership,
showFacepile?: boolean,
showAvatar?: boolean,
showFacepile: boolean,
renderActions: ({ openMembersModal: () => void }) => React.Node,
};
@@ -50,11 +48,6 @@ class GroupListItem extends React.Component<Props> {
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
}
@@ -91,15 +84,6 @@ 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;
-112
View File
@@ -1,112 +0,0 @@
// @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);
+6 -8
View File
@@ -72,28 +72,24 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
${breakpoint("tablet")`
position: unset;
`};
`;
const Wrapper = styled(Flex)`
position: sticky;
top: 0;
z-index: ${(props) => props.theme.depths.header};
z-index: 2;
background: ${(props) => transparentize(0.2, props.theme.background)};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
min-height: 56px;
justify-content: flex-start;
@media print {
display: none;
}
justify-content: flex-start;
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
@@ -101,7 +97,6 @@ const Wrapper = styled(Flex)`
`;
const Title = styled("div")`
display: none;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
@@ -110,9 +105,12 @@ 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 {
+2 -3
View File
@@ -4,10 +4,10 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import { fadeAndSlideDown } from "styles/animations";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
@@ -136,7 +136,7 @@ function HoverPreview({ node, ...rest }: Props) {
}
const Animate = styled.div`
animation: ${fadeAndSlideDown} 150ms ease;
animation: ${fadeAndSlideIn} 150ms ease;
@media print {
display: none;
@@ -201,7 +201,6 @@ 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%;
+1 -4
View File
@@ -32,10 +32,7 @@ import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
import(
/* webpackChunkName: "twitter-picker" */
"react-color/lib/components/twitter/Twitter"
)
import("react-color/lib/components/twitter/Twitter")
);
export const icons = {
+2 -11
View File
@@ -29,20 +29,12 @@ const RealInput = styled.input`
background: none;
color: ${(props) => props.theme.text};
height: 30px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
@@ -110,9 +102,8 @@ export type Props = {|
onChange?: (
ev: SyntheticInputEvent<HTMLInputElement | HTMLTextAreaElement>
) => mixed,
onKeyDown?: (ev: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => mixed,
onBlur?: (ev: SyntheticEvent<>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => void,
onBlur?: (ev: SyntheticEvent<>) => void,
|};
@observer
+2 -4
View File
@@ -3,12 +3,10 @@ import styled from "styled-components";
import Input from "./Input";
const InputLarge = styled(Input)`
height: 38px;
flex-grow: 1;
margin-right: 8px;
height: 40px;
input {
height: 38px;
height: 40px;
}
`;
+38 -38
View File
@@ -1,58 +1,58 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
ui: UiStore,
|};
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
const [focused, setFocused] = React.useState<boolean>(false);
const { ui } = useStores();
@observer
class InputRich extends React.Component<Props> {
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);
handleBlur = () => {
this.focused = false;
};
const handleFocus = React.useCallback(() => {
setFocused(true);
}, []);
handleFocus = () => {
this.focused = true;
};
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={focused}
>
<React.Suspense
fallback={
<HelpText>
<Trans>Loading editor</Trans>
</HelpText>
}
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
>
<Editor
onBlur={handleBlur}
onFocus={handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
}
}
const StyledOutline = styled(Outline)`
@@ -67,4 +67,4 @@ const StyledOutline = styled(Outline)`
}
`;
export default observer(withTheme(InputRich));
export default inject("ui")(withTheme(InputRich));
+78 -37
View File
@@ -1,48 +1,89 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input, { type Props as InputProps } from "./Input";
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 = {|
...InputProps,
type Props = {
history: RouterHistory,
theme: Theme,
source: string,
placeholder?: string,
value?: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|};
label?: string,
labelHidden?: boolean,
collectionId?: string,
t: TFunction,
};
export default function InputSearch(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false);
@observer
class InputSearch extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
const handleFocus = React.useCallback(() => {
setIsFocused(true);
}, []);
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
const handleBlur = React.useCallback(() => {
setIsFocused(false);
}, []);
if (this.input) {
this.input.focus();
}
}
const { placeholder = `${t("Search")}`, onKeyDown, ...rest } = props;
handleSearchInput = (ev: SyntheticInputEvent<>) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
};
return (
<Input
type="search"
placeholder={placeholder}
icon={
<SearchIcon
color={isFocused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
margin={0}
labelHidden
{...rest}
/>
);
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}
/>
);
}
}
const InputMaxWidth = styled(Input)`
max-width: 30vw;
`;
export default withTranslation()<InputSearch>(
withTheme(withRouter(InputSearch))
);
-95
View File
@@ -1,95 +0,0 @@
// @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))
);
+1 -5
View File
@@ -16,10 +16,6 @@ const Select = styled.select`
color: ${(props) => props.theme.text};
height: 30px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
@@ -31,7 +27,7 @@ const Wrapper = styled.label`
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
export type Option = { label: string, value: string };
type Option = { label: string, value: string };
export type Props = {
value?: string,
-22
View File
@@ -1,22 +0,0 @@
// @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}
/>
);
}
+5 -3
View File
@@ -17,10 +17,12 @@ const Labeled = ({ label, children, ...props }: Props) => (
);
export const Label = styled(Flex)`
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${(props) => props.theme.text};
text-transform: uppercase;
color: ${(props) => props.theme.textTertiary};
letter-spacing: 0.04em;
`;
export default observer(Labeled);
+4 -4
View File
@@ -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>
<Guide
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
</Modal>
</Container>
);
}
@@ -202,7 +202,7 @@ const Content = styled(Flex)`
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@media print {
margin: 0 !important;
margin: 0;
}
${breakpoint("mobile", "tablet")`
+12 -27
View File
@@ -8,26 +8,17 @@ type Props = {
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
};
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const ListItem = ({ image, title, subtitle, actions }: Props) => {
const compact = !subtitle;
return (
<Wrapper compact={compact} $border={border}>
<Wrapper compact={compact}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
<Heading>{title}</Heading>
{subtitle && <Subtitle>{subtitle}</Subtitle>}
</Content>
{actions && <Actions>{actions}</Actions>}
</Wrapper>
@@ -36,11 +27,9 @@ const ListItem = ({
const Wrapper = styled.li`
display: flex;
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};
padding: ${(props) => (props.compact ? "8px" : "12px")} 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
&:last-child {
border-bottom: 0;
@@ -49,19 +38,16 @@ const Wrapper = styled.li`
const Image = styled(Flex)`
padding: 0 8px 0 0;
max-height: 32px;
max-height: 40px;
align-items: center;
user-select: none;
flex-shrink: 0;
align-self: center;
align-self: flex-start;
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-size: 16px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
margin: 0;
`;
@@ -72,9 +58,8 @@ const Content = styled(Flex)`
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
font-size: 14px;
color: ${(props) => props.theme.slate};
`;
const Actions = styled.div`
+5 -6
View File
@@ -4,19 +4,18 @@ import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
type Props = {
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
const Placeholder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<PlaceholderText header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} />
<Mask />
</Item>
))}
</Fade>
@@ -24,7 +23,7 @@ const ListPlaceHolder = ({ count }: Props) => {
};
const Item = styled(Flex)`
padding: 10px 0;
padding: 15px 0 16px;
`;
export default ListPlaceHolder;
export default Placeholder;
@@ -11,14 +11,16 @@ const LoadingIndicatorBar = () => {
};
const loadingFrame = keyframes`
from { margin-left: -100%; }
to { margin-left: 100%; }
from {margin-left: -100%; z-index:100;}
to {margin-left: 100%; }
`;
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
background-color: #03a9f4;
width: 100%;
animation: ${loadingFrame} 4s ease-in-out infinite;
animation-delay: 250ms;
@@ -28,7 +30,7 @@ const Container = styled.div`
const Loader = styled.div`
width: 100%;
height: 2px;
background-color: ${(props) => props.theme.primary};
background-color: #03a9f4;
`;
export default LoadingIndicatorBar;
@@ -0,0 +1,30 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
type Props = {
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<Mask header />
<Mask />
</Item>
))}
</Fade>
);
};
const Item = styled(Flex)`
padding: 10px 0;
`;
export default ListPlaceHolder;
@@ -4,19 +4,18 @@ import styled from "styled-components";
import DelayedMount from "components/DelayedMount";
import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
export default function PlaceholderDocument(props: Object) {
export default function LoadingPlaceholder(props: Object) {
return (
<DelayedMount>
<Wrapper>
<Flex column auto {...props}>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<Mask height={34} />
<br />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
<Mask />
<Mask />
<Mask />
</Flex>
</Wrapper>
</DelayedMount>
@@ -0,0 +1,6 @@
// @flow
import ListPlaceholder from "./ListPlaceholder";
import LoadingPlaceholder from "./LoadingPlaceholder";
export default LoadingPlaceholder;
export { ListPlaceholder };
+13 -31
View File
@@ -1,38 +1,20 @@
// @flow
import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
faIR,
fr,
es,
it,
ja,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import format from "date-fns/format";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fa_IR: faIR,
fr_FR: fr,
it_IT: it,
ja_JP: ja,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
en: require(`date-fns/locale/en`),
de: require(`date-fns/locale/de`),
es: require(`date-fns/locale/es`),
fr: require(`date-fns/locale/fr`),
it: require(`date-fns/locale/it`),
ko: require(`date-fns/locale/ko`),
pt: require(`date-fns/locale/pt`),
zh: require(`date-fns/locale/zh_cn`),
ru: require(`date-fns/locale/ru`),
};
let callbacks = [];
@@ -82,7 +64,7 @@ function LocaleTime({
};
}, []);
let content = formatDistanceToNow(Date.parse(dateTime), {
let content = distanceInWordsToNow(dateTime, {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
});
@@ -96,7 +78,7 @@ function LocaleTime({
return (
<Tooltip
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
@@ -2,48 +2,42 @@
import * as React from "react";
import styled from "styled-components";
import { randomInteger } from "shared/random";
import { pulsate } from "shared/styles/animations";
import Flex from "components/Flex";
import { pulsate } from "styles/animations";
type Props = {|
header?: boolean,
height?: number,
minWidth?: number,
maxWidth?: number,
delay?: number,
|};
class PlaceholderText extends React.Component<Props> {
width = randomInteger(this.props.minWidth || 75, this.props.maxWidth || 100);
class Mask extends React.Component<Props> {
width: number;
shouldComponentUpdate() {
return false;
}
constructor() {
super();
this.width = randomInteger(75, 100);
}
render() {
return (
<Mask
width={this.width}
height={this.props.height}
delay={this.props.delay}
/>
);
return <Redacted width={this.width} />;
}
}
const Mask = styled(Flex)`
const Redacted = styled(Flex)`
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
height: ${(props) =>
props.height ? props.height : props.header ? 24 : 18}px;
margin-bottom: 6px;
border-radius: 6px;
background-color: ${(props) => props.theme.divider};
animation: ${pulsate} 2s infinite;
animation-delay: ${(props) => props.delay || 0}s;
animation: ${pulsate} 1.3s infinite;
&:last-child {
margin-bottom: 0;
}
`;
export default PlaceholderText;
export default Mask;
+1 -1
View File
@@ -7,12 +7,12 @@ import { useTranslation } from "react-i18next";
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";
import { fadeAndScaleIn } from "styles/animations";
let openModals = 0;
+2 -2
View File
@@ -3,8 +3,8 @@ import * as React from "react";
import styled from "styled-components";
const Button = styled.button`
width: ${(props) => props.width || props.size}px;
height: ${(props) => props.height || props.size}px;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
background: none;
border-radius: 4px;
line-height: 0;
+11
View File
@@ -19,6 +19,17 @@ 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) {
+6 -10
View File
@@ -19,16 +19,12 @@ const PageTitle = ({ title, favicon }: Props) => {
<title>
{team && team.name ? `${title} - ${team.name}` : `${title} - Outline`}
</title>
{favicon ? (
<link rel="shortcut icon" href={favicon} />
) : (
<link
rel="shortcut icon"
type="image/png"
href={cdnPath("/favicon-32.png")}
sizes="32x32"
/>
)}
<link
rel="shortcut icon"
type="image/png"
href={favicon || cdnPath("/favicon-32.png")}
sizes="32x32"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet>
);
+20 -21
View File
@@ -1,4 +1,5 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
@@ -18,26 +19,24 @@ type Props = {|
showTemplate?: boolean,
|};
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
empty,
heading,
documents,
fetch,
options,
...rest
}: Props) {
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
});
@observer
class PaginatedDocumentList extends React.Component<Props> {
render() {
const { empty, heading, documents, fetch, options, ...rest } = this.props;
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
}
}
export default PaginatedDocumentList;
+6 -16
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { Waypoint } from "react-waypoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { ListPlaceholder } from "components/LoadingPlaceholder";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@@ -38,24 +38,14 @@ class PaginatedList extends React.Component<Props> {
}
componentDidUpdate(prevProps: Props) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
if (!isEqual(prevProps.options, this.props.options)) {
this.fetchResults();
}
}
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false;
this.isFetchingMore = false;
this.isLoaded = false;
};
fetchResults = async () => {
if (!this.props.fetch) return;
@@ -128,7 +118,7 @@ class PaginatedList extends React.Component<Props> {
)}
{showLoading && (
<DelayedMount>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</DelayedMount>
)}
</>
-84
View File
@@ -1,84 +0,0 @@
// @flow
import "../stores";
import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
it("with no items renders nothing", () => {
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
expect(list).toEqual({});
});
it("with no items renders empty prop", () => {
const list = shallow(
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
/>
);
expect(list.text()).toEqual("Sorry, no results");
});
it("calls fetch with options + pagination on mount", () => {
const fetch = jest.fn();
const options = { id: "one" };
shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
/>
);
expect(fetch).toHaveBeenCalledWith({
...options,
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
it("calls fetch when options prop changes", async () => {
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
const fetch = jest.fn().mockReturnValue(fetchedItems);
const list = shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={{ id: "one" }}
renderItem={render}
/>
);
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "one",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
fetch.mockReset();
list.setProps({
fetch,
items: [],
options: { id: "two" },
});
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "two",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
});
-34
View File
@@ -1,34 +0,0 @@
// @flow
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "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;
+1 -7
View File
@@ -12,7 +12,6 @@ type Props = {|
children: React.Node,
breadcrumb?: React.Node,
actions?: React.Node,
centered?: boolean,
|};
function Scene({
@@ -22,7 +21,6 @@ function Scene({
actions,
breadcrumb,
children,
centered,
}: Props) {
return (
<FillWidth>
@@ -40,11 +38,7 @@ function Scene({
actions={actions}
breadcrumb={breadcrumb}
/>
{centered !== false ? (
<CenteredContent withStickyHeader>{children}</CenteredContent>
) : (
children
)}
<CenteredContent withStickyHeader>{children}</CenteredContent>
</FillWidth>
);
}
+57 -46
View File
@@ -1,13 +1,15 @@
// @flow
import { observer } from "mobx-react";
import {
EditIcon,
ArchiveIcon,
HomeIcon,
PlusIcon,
EditIcon,
SearchIcon,
SettingsIcon,
ShapesIcon,
StarredIcon,
ShapesIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
@@ -21,22 +23,16 @@ import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
function MainSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const { policies, auth, documents } = useStores();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
@@ -75,6 +71,9 @@ function MainSidebar() {
dndArea,
]);
const { user, team } = auth;
if (!user || !team) return null;
const can = policies.abilities(team.id);
return (
@@ -115,25 +114,32 @@ function MainSidebar() {
exact={false}
label={t("Starred")}
/>
{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
}
/>
)}
<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
}
/>
</Section>
<Section auto>
<Collections
@@ -141,21 +147,26 @@ function MainSidebar() {
/>
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
<ArchiveLink documents={documents} />
<TrashLink documents={documents} />
</>
)}
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
@@ -164,7 +175,7 @@ function MainSidebar() {
/>
{can.inviteUser && (
<SidebarLink
to="/settings/members"
to="/settings/people"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
+9 -11
View File
@@ -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,13 +71,11 @@ function SettingsSidebar() {
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
{can.createApiKey && (
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
)}
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
</Section>
<Section>
<Header>{t("Team")}</Header>
@@ -96,10 +94,10 @@ function SettingsSidebar() {
/>
)}
<SidebarLink
to="/settings/members"
to="/settings/people"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("Members")}
label={t("People")}
/>
<SidebarLink
to="/settings/groups"
+6 -5
View File
@@ -12,7 +12,6 @@ import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
import { fadeIn } from "styles/animations";
let ANIMATION_MS = 250;
let isFirstRender = true;
@@ -155,7 +154,9 @@ const Sidebar = React.forwardRef<Props, HTMLButtonElement>(
<>
{ui.mobileSidebarVisible && (
<Portal>
<Backdrop onClick={ui.toggleMobileSidebar} />
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
@@ -202,8 +203,7 @@ const Sidebar = React.forwardRef<Props, HTMLButtonElement>(
}
);
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
const Background = styled.a`
position: fixed;
top: 0;
left: 0;
@@ -211,7 +211,7 @@ const Backdrop = styled.a`
right: 0;
cursor: default;
z-index: ${(props) => props.theme.depths.sidebar - 1};
background: ${(props) => props.theme.backdrop};
background: rgba(0, 0, 0, 0.5);
`;
const Container = styled(Flex)`
@@ -242,6 +242,7 @@ const Container = styled(Flex)`
${breakpoint("tablet")`
margin: 0;
z-index: 3;
min-width: 0;
transform: translateX(${(props) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
@@ -1,43 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useToasts from "hooks/useToasts";
function ArchiveLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
accept: "document",
drop: async (item, monitor) => {
const document = documents.get(item.id);
await document.archive();
showToast(t("Document archived"), { type: "success" });
},
canDrop: (item, monitor) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
active={documents.active?.isArchived && !documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
);
}
export default observer(ArchiveLink);
@@ -7,12 +7,11 @@ 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 useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";
import CollectionSortMenu from "menus/CollectionSortMenu";
@@ -36,7 +35,7 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -164,14 +163,14 @@ function CollectionLink({
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
}
@@ -9,52 +9,28 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionsLoading from "./CollectionsLoading";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
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 { showToast } = useToasts();
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(() => {
async function load() {
if (!collections.isLoaded && !isFetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({ limit: 100 });
} catch (error) {
showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
}
);
setFetchError(error);
} finally {
setFetching(false);
}
}
if (!collections.isLoaded) {
collections.fetchPage({ limit: 100 });
}
load();
}, [collections, isFetching, showToast, fetchError, t]);
});
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
@@ -92,23 +68,21 @@ function Collections({ onCreateCollection }: Props) {
belowCollection={orderedCollections[index + 1]}
/>
))}
{can.createCollection && (
<SidebarLink
to="/collections"
onClick={onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
/>
)}
<SidebarLink
to="/collections"
onClick={onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
/>
</>
);
if (!collections.isLoaded || fetchError) {
if (!collections.isLoaded) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<PlaceholderCollections />
<CollectionsLoading />
</Flex>
);
}
@@ -0,0 +1,21 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Mask from "components/Mask";
function CollectionsLoading() {
return (
<Wrapper>
<Mask />
<Mask />
<Mask />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default CollectionsLoading;
@@ -7,12 +7,11 @@ 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 useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { type NavigationNode } from "types";
@@ -28,19 +27,16 @@ type Props = {|
parentId?: string,
|};
function DocumentLink(
{
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props,
ref
) {
function DocumentLink({
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@@ -121,7 +117,7 @@ function DocumentLink(
[documents, document]
);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
@@ -133,11 +129,7 @@ function DocumentLink(
isDragging: !!monitor.isDragging(),
}),
canDrag: (monitor) => {
return (
policies.abilities(node.id).move ||
policies.abilities(node.id).archive ||
policies.abilities(node.id).delete
);
return policies.abilities(node.id).move;
},
});
@@ -244,14 +236,13 @@ function DocumentLink(
depth={depth}
exact={false}
showActions={menuOpen}
ref={ref}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
@@ -291,13 +282,11 @@ const Draggable = styled("div")`
`;
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
const ObservedDocumentLink = observer(DocumentLink);
export default ObservedDocumentLink;
@@ -1,87 +0,0 @@
// @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";
import useToasts from "hooks/useToasts";
type Props = {|
children: React.Node,
collectionId: string,
documentId?: string,
disabled: boolean,
staticContext: Object,
|};
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
);
const can = policies.abilities(collectionId);
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, showToast]);
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);
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import useToasts from "hooks/useToasts";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: (title: string) => Promise<void>,
@@ -13,7 +13,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const { showToast } = useToasts();
const { ui } = useStores();
React.useEffect(() => {
setValue(title);
@@ -39,40 +39,32 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
[originalValue]
);
const handleSave = React.useCallback(
async (ev) => {
ev.preventDefault();
const handleSave = React.useCallback(async () => {
setIsEditing(false);
setIsEditing(false);
if (value === originalValue) {
return;
}
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
if (document) {
try {
await onSubmit(value);
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
return;
ui.showToast(error.message, {
type: "error",
});
throw error;
}
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
showToast(error.message, {
type: "error",
});
throw error;
}
}
},
[originalValue, showToast, value, onSubmit]
);
}
}, [ui, originalValue, value, onSubmit]);
return (
<>
{isEditing ? (
<form onSubmit={handleSave}>
<Input
dir="auto"
type="text"
value={value}
onKeyDown={handleKeyDown}
@@ -1,21 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import PlaceholderText from "components/PlaceholderText";
function PlaceholderCollections() {
return (
<Wrapper>
<PlaceholderText />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default PlaceholderCollections;
@@ -7,7 +7,7 @@ const ResizeBorder = styled.div`
bottom: 0;
right: -6px;
width: 12px;
cursor: col-resize;
cursor: ew-resize;
`;
export default ResizeBorder;
@@ -1,5 +1,4 @@
// @flow
import { transparentize } from "polished";
import * as React from "react";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -30,28 +29,25 @@ type Props = {
depth?: number,
};
function SidebarLink(
{
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props,
ref
) {
function SidebarLink({
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
@@ -70,25 +66,22 @@ 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}
ref={ref}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
</Link>
<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>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
</Link>
);
}
@@ -116,8 +109,6 @@ const Actions = styled(EventBoundary)`
}
&:hover {
display: inline-flex;
svg {
opacity: 0.75;
}
@@ -135,7 +126,7 @@ const Link = styled(NavLink)`
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 16px;
font-size: 15px;
cursor: pointer;
overflow: hidden;
@@ -144,33 +135,30 @@ 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) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
background: ${(props) => props.theme.black05};
}
&:hover,
&:active {
> ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
${breakpoint("tablet")`
padding: 4px 32px 4px 16px;
font-size: 15px;
padding: 4px 16px;
`}
@media (hover: hover) {
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
&:hover {
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
}
`;
const Label = styled.div`
@@ -178,9 +166,6 @@ const Label = styled.div`
width: 100%;
max-height: 4.8em;
line-height: 1.6;
* {
unicode-bidi: plaintext;
}
`;
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
export default withRouter(withTheme(SidebarLink));
@@ -68,19 +68,18 @@ const Wrapper = styled.div`
const Header = styled.button`
display: flex;
align-items: center;
padding: 20px 24px;
background: none;
line-height: inherit;
border: 0;
padding: 8px;
margin: 8px;
border-radius: 4px;
margin: 0;
cursor: pointer;
width: calc(100% - 16px);
width: 100%;
&:active,
&:hover {
transition: background 100ms ease-in-out;
background: ${(props) => props.theme.sidebarItemBackground};
background: rgba(0, 0, 0, 0.05);
}
`;
@@ -1,62 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useState } from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
function TrashLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const [document, setDocument] = useState();
const [{ isDocumentDropping }, dropToTrashDocument] = useDrop({
accept: "document",
drop: (item, monitor) => {
const doc = documents.get(item.id);
// without setTimeout it was not working in firefox v89.0.2-ubuntu
// on dropping mouseup is considered as clicking outside the modal, and it immediately closes
setTimeout(() => setDocument(doc), 1);
},
canDrop: (item, monitor) => policies.abilities(item.id).delete,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
active={documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
{document && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setDocument(undefined)}
isOpen
>
<DocumentDelete
document={document}
onSubmit={() => setDocument(undefined)}
/>
</Modal>
)}
</>
);
}
export default observer(TrashLink);
+7 -15
View File
@@ -11,7 +11,7 @@ import DocumentsStore from "stores/DocumentsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import ViewsStore from "stores/ViewsStore";
import { getVisibilityListener, getPageVisible } from "utils/pageVisibility";
@@ -27,7 +27,7 @@ type Props = {
policies: PoliciesStore,
views: ViewsStore,
auth: AuthStore,
toasts: ToastsStore,
ui: UiStore,
};
@observer
@@ -72,7 +72,7 @@ class SocketProvider extends React.Component<Props> {
const {
auth,
toasts,
ui,
documents,
collections,
groups,
@@ -101,10 +101,7 @@ 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 =
auth.team && auth.team.domain
? ["websocket"]
: ["websocket", "polling"];
this.socket.io.opts.transports = ["polling", "websocket"];
});
this.socket.on("authenticated", () => {
@@ -113,7 +110,7 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("unauthorized", (err) => {
this.socket.authenticated = false;
toasts.showToast(err.message, {
ui.showToast(err.message, {
type: "error",
});
throw err;
@@ -144,10 +141,9 @@ class SocketProvider extends React.Component<Props> {
// otherwise, grab the latest version of the document
try {
const response = await documents.fetch(documentId, {
document = await documents.fetch(documentId, {
force: true,
});
document = response.document;
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);
@@ -250,10 +246,6 @@ class SocketProvider extends React.Component<Props> {
documents.starredIds.set(event.documentId, false);
});
this.socket.on("documents.permanent_delete", (event) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event) => {
@@ -338,7 +330,7 @@ class SocketProvider extends React.Component<Props> {
export default inject(
"auth",
"toasts",
"ui",
"documents",
"collections",
"groups",
-4
View File
@@ -61,10 +61,6 @@ export const AnimatedStar = styled(StarredIcon)`
&:active {
transform: scale(0.95);
}
@media print {
display: none;
}
`;
export default Star;
+8 -49
View File
@@ -1,26 +1,14 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
const TabLink = styled(NavLink)`
position: relative;
display: inline-flex;
align-items: center;
@@ -32,48 +20,19 @@ const TabLink = styled(NavLinkWithChildrenFunc)`
&:hover {
color: ${(props) => props.theme.textSecondary};
border-bottom: 3px solid ${(props) => props.theme.divider};
padding-bottom: 5px;
}
`;
const Active = styled(m.div)`
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
width: 100%;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
background: ${(props) => props.theme.textSecondary};
`;
const transition = {
type: "spring",
stiffness: 500,
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
function Tab({ theme, ...rest }: Props) {
const activeStyle = {
paddingBottom: "5px",
borderBottom: `3px solid ${theme.textSecondary}`,
color: theme.textSecondary,
};
return (
<TabLink {...rest} activeStyle={activeStyle}>
{(match) => (
<>
{children}
{match && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
</>
)}
</TabLink>
);
return <TabLink {...rest} activeStyle={activeStyle} />;
}
export default withTheme(Tab);
-250
View File
@@ -1,250 +0,0 @@
// @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 PlaceholderText from "components/PlaceholderText";
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}>
<PlaceholderText 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);
+5 -57
View File
@@ -1,40 +1,13 @@
// @flow
import { AnimateSharedLayout } from "framer-motion";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import useWindowSize from "hooks/useWindowSize";
const Nav = styled.nav`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 12px 0;
overflow-y: auto;
white-space: nowrap;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&:after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
pointer-events: none;
background: ${(props) =>
props.$shadowVisible
? `linear-gradient(
90deg,
${transparentize(1, props.theme.background)} 0%,
${props.theme.background} 100%
)`
: `transparent`};
}
transition: opacity 250ms ease-out;
`;
// When sticky we need extra background coverage around the sides otherwise
@@ -57,36 +30,11 @@ export const Separator = styled.span`
margin-top: 6px;
`;
const Tabs = ({ children }: {| children: React.Node |}) => {
const ref = React.useRef<?HTMLDivElement>();
const [shadowVisible, setShadow] = React.useState(false);
const { width } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = ref.current;
if (!c) return;
const scrollLeft = c.scrollLeft;
const wrapperWidth = c.scrollWidth - c.clientWidth;
const fade = !!(wrapperWidth - scrollLeft !== 0);
if (fade !== shadowVisible) {
setShadow(fade);
}
}, [shadowVisible]);
React.useEffect(() => {
updateShadows();
}, [width, updateShadows]);
const Tabs = (props: {}) => {
return (
<AnimateSharedLayout>
<Sticky>
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
{children}
</Nav>
</Sticky>
</AnimateSharedLayout>
<Sticky>
<Nav {...props}></Nav>
</Sticky>
);
};
+14 -23
View File
@@ -1,34 +1,25 @@
// @flow
import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { ThemeProvider } from "styled-components";
import { dark, light, lightMobile, darkMobile } from "shared/theme";
import useMediaQuery from "hooks/useMediaQuery";
import useStores from "hooks/useStores";
import GlobalStyles from "styles/globals";
import GlobalStyles from "shared/styles/globals";
import { dark, light } from "shared/styles/theme";
import UiStore from "stores/UiStore";
const empty = {};
type Props = {|
type Props = {
ui: UiStore,
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={theme}>
<ThemeProvider theme={isMobile ? mobileTheme : empty}>
<>
<GlobalStyles />
{children}
</>
</ThemeProvider>
<ThemeProvider theme={ui.resolvedTheme === "dark" ? dark : light}>
<>
<GlobalStyles />
{children}
</>
</ThemeProvider>
);
}
export default observer(Theme);
export default inject("ui")(observer(Theme));
+4 -6
View File
@@ -1,10 +1,8 @@
// @flow
import { formatDistanceToNow } from "date-fns";
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import * as React from "react";
const LocaleTime = React.lazy(() =>
import(/* webpackChunkName: "locale-time" */ "components/LocaleTime")
);
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
type Props = {
dateTime: string,
@@ -15,7 +13,7 @@ type Props = {
};
function Time(props: Props) {
let content = formatDistanceToNow(Date.parse(props.dateTime), {
let content = distanceInWordsToNow(props.dateTime, {
addSuffix: props.addSuffix,
});
@@ -32,7 +30,7 @@ function Time(props: Props) {
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime tooltipDelay={250} {...props} />
<LocaleTime {...props} />
</React.Suspense>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled, { css } from "styled-components";
import { fadeAndScaleIn, pulse } from "styles/animations";
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
import type { Toast as TToast } from "types";
type Props = {
+3 -3
View File
@@ -6,15 +6,15 @@ import Toast from "components/Toast";
import useStores from "hooks/useStores";
function Toasts() {
const { toasts } = useStores();
const { ui } = useStores();
return (
<List>
{toasts.orderedData.map((toast) => (
{ui.orderedToasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onRequestClose={() => toasts.hideToast(toast.id)}
onRequestClose={() => ui.removeToast(toast.id)}
/>
))}
</List>
-28
View File
@@ -1,28 +0,0 @@
// @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"
/>
);
}
}
-39
View File
@@ -1,39 +0,0 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDataStudio extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("u/0", "embed").replace("/edit", "")}
icon={
<Image
src="/images/google-datastudio.png"
alt="Google Data Studio Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Data Studio"
border
/>
);
}
}

Some files were not shown because too many files have changed in this diff Show More