Compare commits

..

15 Commits

Author SHA1 Message Date
Tom Moor d4c594423f More email styling 2021-07-08 22:38:42 -04:00
Tom Moor 2bf237d54b Merge branch 'main' into email-diff 2021-07-08 21:20:18 -04:00
Tom Moor 3565e68725 Merge branch 'main' into email-diff 2021-07-06 20:43:51 -04:00
Tom Moor 61039e9d0d Allow images in email diff 2021-06-25 09:41:34 -07:00
Tom Moor 6d09122d56 test: Deletion 2021-06-24 20:10:42 -07:00
Tom Moor 5fb6097153 Improved diff 2021-06-23 23:58:32 -07:00
Tom Moor ec17874568 Remove test harness 2021-06-22 07:35:38 -07:00
Tom Moor 40c3e9e85f test 2021-06-22 07:27:55 -07:00
Tom Moor 9f739f3788 Merge main 2021-06-22 07:26:45 -07:00
Tom Moor f6837b4742 wip 2021-06-20 23:15:04 -07:00
Tom Moor 1560e3c9f7 refactor 2021-06-20 12:49:15 -07:00
Tom Moor ca74908dc5 test 2021-06-20 00:20:37 -07:00
Tom Moor de7ec1119b Integrate into mailer, basic styling 2021-06-19 23:50:36 -07:00
Tom Moor 2093b4297f Merge main 2021-06-19 17:05:19 -07:00
Nan Yu 3df82c500b wip 2021-02-21 11:52:00 -08:00
469 changed files with 7347 additions and 16898 deletions
+2 -94
View File
@@ -1,12 +1,4 @@
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
docker:
- image: circleci/buildpack-deps:stretch
version: 2
jobs:
build:
working_directory: ~/outline
@@ -48,88 +40,4 @@ jobs:
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
build-and-test:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
command: yarn build:webpack
+17 -39
View File
@@ -1,6 +1,6 @@
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# setting the Slack keys and the SECRET_KEY.
@@ -12,7 +12,7 @@
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
@@ -29,13 +29,9 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# ALPHA See [documentation](docs/SERVICES.md) on running the alpha version of
# the collaboration server.
COLLABORATION_URL=
# 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
# 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:
@@ -73,38 +69,24 @@ 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
# 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_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# To configure generic OIDC auth, you'll need some kind of identity provider.
# See documentation for whichever IdP you use to acquire the following info:
# Redirect URI is https://<URL>/auth/oidc.callback
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
# Display name for OIDC authentication
OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# –––––––––––––––– OPTIONAL ––––––––––––––––
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
@@ -112,23 +94,19 @@ 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 can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
# 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
# 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
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
@@ -136,13 +114,13 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# To support sending outgoing transactional emails such as "document updated" or
# 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=
SMTP_PORT=
@@ -156,6 +134,6 @@ 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
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
-5
View File
@@ -11,11 +11,6 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/y-indexeddb/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+3 -4
View File
@@ -1,3 +1,4 @@
# Architecture
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
@@ -45,9 +46,7 @@ server
├── onboarding - Markdown templates for onboarding documents
├── policies - Authorization logic based on cancan
├── presenters - JSON presenters for database models, the interface between backend -> frontend
├── queues - Async queue definitions
│ └── processors - Processors perform async jobs, usually working on events from the event bus
├── services - Services start distinct portions of the application eg api, worker
├── services - Service definitions are triggered for events and perform async jobs
├── static - Static assets
├── test - Test helpers and fixtures, tests themselves are colocated
└── utils - Utility methods specific to the backend
@@ -65,4 +64,4 @@ shared
├── styles - Styles, colors and other global aesthetics
├── utils - Shared utility methods
└── constants - Shared constants
```
```
+12 -39
View File
@@ -1,50 +1,23 @@
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:14-alpine AS deps-common
FROM node:14-alpine
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
COPY package.json ./
COPY yarn.lock ./
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:14-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
RUN yarn --pure-lockfile
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
RUN yarn build
# ---
FROM node:14-alpine AS runner
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
CMD yarn start
EXPOSE 3000
CMD ["yarn", "start"]
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.59.0
Licensed Work: Outline 0.55.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2025-09-07
Change Date: 2024-04-22
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -2,7 +2,7 @@ up:
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev:watch
yarn dev
build:
docker-compose build --pull outline
+1 -2
View File
@@ -1,2 +1 @@
web: yarn start --services=web,websockets
worker: yarn start --services=worker
web: node ./build/server/index.js
+29 -24
View File
@@ -1,3 +1,5 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
@@ -28,6 +30,7 @@ Outline requires the following dependencies:
- AWS S3 bucket or compatible API for file storage
- Slack or Google developer application for authentication
## Self-Hosted Production
### Docker
@@ -38,20 +41,16 @@ For a manual self-hosted production installation these are the recommended steps
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db:migrate`
1. Start the container:
`docker run outlinewiki/outline`
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
@@ -80,27 +79,29 @@ If you're running Outline by cloning this repository, run the following command
yarn run upgrade
```
## Local Development
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`
1. Fill out the following fields:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
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. Ensure that the bot token scope contains at least `users:read`
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
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. 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
# Contributing
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
@@ -109,22 +110,26 @@ Before submitting a pull request please let the core team know by creating or co
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs and other issues listed on GitHub
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
```
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
```
## Tests
@@ -140,7 +145,7 @@ make test
make watch
```
Once the test database is created with `make test` you may individually run
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
View File
+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) {
+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,
+3 -7
View File
@@ -66,11 +66,7 @@ const RealButton = styled.button`
&:hover {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
@@ -128,11 +124,11 @@ export type Props = {|
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any> | string,
as?: React.ComponentType<any>,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
href?: string,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
-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;
+2 -4
View File
@@ -12,19 +12,17 @@ import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
currentUserId: string,
|};
function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const { users, presence } = useStores();
const { document } = props;
const { document, currentUserId } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
+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",
})
-59
View File
@@ -1,59 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
${breakpoint("tablet")`
display: block;
`};
@media print {
display: none;
}
`;
const Centered = styled.div`
text-align: center;
`;
export default observer(ConnectionStatus);
+2 -8
View File
@@ -4,7 +4,6 @@ import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
@@ -16,8 +15,6 @@ type Props = {|
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
icon?: React.Node,
|};
const MenuItem = ({
@@ -27,7 +24,6 @@ const MenuItem = ({
disabled,
as,
hide,
icon,
...rest
}: Props) => {
const handleClick = React.useCallback(
@@ -74,7 +70,6 @@ const MenuItem = ({
&nbsp;
</>
)}
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{children}
</MenuAnchor>
)}
@@ -93,7 +88,6 @@ export const MenuAnchor = styled.a`
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
width: 100%;
min-height: 32px;
background: none;
@@ -134,8 +128,8 @@ export const MenuAnchor = styled.a`
`};
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
`};
`;
+43 -33
View File
@@ -9,13 +9,49 @@ import {
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Flex from "components/Flex";
import MenuIconWrapper from "components/MenuIconWrapper";
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[],
@@ -69,15 +105,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
}
function Template({ items, ...menu }: Props): React.Node {
const filteredTemplates = filterTemplateItems(items);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem)
item.icon = item.icon ? item.icon : <MenuIconWrapper />;
return filterTemplateItems(items).map((item, index) => {
if (item.to) {
return (
<MenuItem
@@ -86,7 +114,6 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
{...menu}
>
{item.title}
@@ -101,9 +128,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"}
icon={item.icon}
target="_blank"
{...menu}
>
{item.title}
@@ -119,7 +144,6 @@ function Template({ items, ...menu }: Props): React.Node {
disabled={item.disabled}
selected={item.selected}
key={index}
icon={item.icon}
{...menu}
>
{item.title}
@@ -133,7 +157,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
as={Submenu}
templateItems={item.items}
title={<Title title={item.title} icon={item.icon} />}
title={item.title}
{...menu}
/>
);
@@ -143,22 +167,8 @@ 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;
});
}
function Title({ title, icon }) {
return (
<Flex align="center">
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+14 -29
View File
@@ -4,18 +4,16 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "styles/animations";
fadeAndScaleIn,
fadeAndSlideIn,
} from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
placement?: string,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
@@ -46,25 +44,13 @@ export default function ContextMenu({
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>
);
}}
{(props) => (
<Position {...props}>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
@@ -105,7 +91,7 @@ const Position = styled.div`
`;
const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
animation: ${fadeAndSlideIn} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${(props) => props.theme.menuBackground};
@@ -123,10 +109,9 @@ const Background = styled.div`
}
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
props.left !== undefined ? "25%" : "75%"} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
-124
View File
@@ -1,124 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
const EMPTY_ARRAY = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
history.push(documentUrl(document));
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);
@@ -0,0 +1,199 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
documentId: this.props.match.params.documentSlug,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
if (this.revisions.length) {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return;
this.props.history.replace(
documentHistoryUrl(document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return [];
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<ListPlaceholder count={5} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={document}
showMenu={index !== 0}
selected={this.props.match.params.revisionId === revision.id}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
</Sidebar>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -0,0 +1,87 @@
// @flow
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Revision from "models/Revision";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
showMenu: boolean,
selected: boolean,
document: Document,
revision: Revision,
};
class RevisionListItem extends React.Component<Props> {
render() {
const { revision, document, showMenu, selected, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 20px;
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
position: relative;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(RevisionListItem);
+3
View File
@@ -0,0 +1,3 @@
// @flow
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;
+4 -7
View File
@@ -15,7 +15,6 @@ 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";
@@ -47,7 +46,7 @@ function DocumentListItem(props: Props, ref) {
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
showNestedDocuments,
@@ -66,7 +65,6 @@ function DocumentListItem(props: Props, ref) {
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
return (
<DocumentLink
@@ -127,8 +125,7 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
can.createDocument && (
<>
<Button
as={Link}
@@ -146,8 +143,8 @@ function DocumentListItem(props: Props, ref) {
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
</Actions>
+10 -22
View File
@@ -6,10 +6,8 @@ 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 Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
@@ -52,9 +50,7 @@ function DocumentMeta({
...rest
}: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
@@ -65,8 +61,6 @@ function DocumentMeta({
deletedAt,
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -75,8 +69,6 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
if (deletedAt) {
@@ -111,16 +103,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,9 +131,13 @@ 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;
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
@@ -155,17 +149,11 @@ function DocumentMeta({
)}
{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>
);
+1 -1
View File
@@ -42,7 +42,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&nbsp;
&nbsp;&middot;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
-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;
+7 -55
View File
@@ -3,15 +3,13 @@ import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import { Extension } from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import embeds from "shared/embeds";
import { light } from "shared/theme";
import { light } from "shared/styles/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
@@ -31,8 +29,6 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
@@ -62,9 +58,8 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
const { id, shareId, history } = props;
const { id, ui, shareId, history } = props;
const { t } = useTranslation();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
@@ -111,9 +106,11 @@ function Editor(props: PropsWithRef) {
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
if (ui) {
ui.showToast(message);
}
},
[showToast]
[ui]
);
const dictionary = React.useMemo(() => {
@@ -151,7 +148,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"),
@@ -249,50 +245,6 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
+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;
-163
View File
@@ -1,163 +0,0 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const isRevision = event.name === "revisions.create";
let meta, icon, to;
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
if (!meta) {
return null;
}
return (
<ListItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={250}
format="MMMM do, h:mm a"
relative={false}
addSuffix
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
/>
);
};
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: 50%;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.25;
transition: opacity 100ms ease-in-out;
}
&:hover {
${Actions} {
opacity: 1;
}
}
`;
export default EventListItem;
+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;
+1 -1
View File
@@ -45,7 +45,7 @@ const Container = styled.div`
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0;
min-width: 0;
`;
+1 -2
View File
@@ -39,8 +39,7 @@ const Guide = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScroll
hideOnEsc
preventBodyScrollhideOnEsc
hide={onRequestClose}
>
{(props) => (
+3 -7
View File
@@ -36,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
}, []);
return (
<Wrapper align="center" shrink={false}>
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
@@ -72,10 +72,6 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
${breakpoint("tablet")`
position: unset;
`};
`;
const Wrapper = styled(Flex)`
@@ -88,14 +84,14 @@ const Wrapper = styled(Flex)`
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: 16px 16px 0;
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
`};
`;
+2 -2
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;
+1 -26
View File
@@ -1,6 +1,5 @@
// @flow
import {
BookmarkedIcon,
CollectionIcon,
CoinsIcon,
AcademicCapIcon,
@@ -9,12 +8,9 @@ import {
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
MathIcon,
MoonIcon,
NotepadIcon,
PadlockIcon,
@@ -22,7 +18,6 @@ import {
QuestionMarkIcon,
SunIcon,
VehicleIcon,
WarningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -44,10 +39,6 @@ const TwitterPicker = React.lazy(() =>
);
export const icons = {
bookmark: {
component: BookmarkedIcon,
keywords: "bookmark",
},
collection: {
component: CollectionIcon,
keywords: "collection",
@@ -76,18 +67,10 @@ export const icons = {
component: CodeIcon,
keywords: "developer api code development engineering programming",
},
email: {
component: EmailIcon,
keywords: "email at",
},
eye: {
component: EyeIcon,
keywords: "eye view",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
},
leaf: {
component: LeafIcon,
keywords: "leaf plant outdoors nature ecosystem climate",
@@ -96,10 +79,6 @@ export const icons = {
component: LightBulbIcon,
keywords: "lightbulb idea",
},
math: {
component: MathIcon,
keywords: "math formula",
},
moon: {
component: MoonIcon,
keywords: "night moon dark",
@@ -132,10 +111,6 @@ export const icons = {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "danger",
},
};
const colors = [
@@ -217,7 +192,7 @@ const Label = styled.label`
`;
const Icons = styled.div`
padding: 16px 8px 0 16px;
padding: 15px 9px 9px 15px;
width: 276px;
`;
@@ -1,6 +1,6 @@
// @flow
import * as React from "react";
import { cdnPath } from "../../utils/urls";
import { cdnPath } from "utils/urls";
type Props = {|
alt: string,
-4
View File
@@ -29,10 +29,6 @@ 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 {
+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));
-6
View File
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Outline, LabelText } from "./Input";
const Select = styled.select`
@@ -16,7 +15,6 @@ const Select = styled.select`
background: none;
color: ${(props) => props.theme.text};
height: 30px;
font-size: 14px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
@@ -26,10 +24,6 @@ const Select = styled.select`
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`;
const Wrapper = styled.label`
-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 "components/InputSelect";
const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{ label: t("Member"), value: "member" },
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
{...props}
/>
);
};
export default InputSelectRole;
+7 -14
View File
@@ -22,6 +22,7 @@ import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
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";
@@ -37,12 +38,6 @@ import {
newDocumentUrl,
} from "utils/routeHelpers";
const DocumentHistory = React.lazy(() =>
import(
/* webpackChunkName: "document-history" */ "components/DocumentHistory"
)
);
type Props = {
documents: DocumentsStore,
children?: ?React.Node,
@@ -159,14 +154,12 @@ class Layout extends React.Component<Props> {
{this.props.children}
</Content>
<React.Suspense>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</Container>
<Guide
isOpen={this.keyboardShortcutsOpen}
+25 -54
View File
@@ -1,62 +1,41 @@
// @flow
import * as React from "react";
import styled, { useTheme } from "styled-components";
import styled from "styled-components";
import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = {|
type Props = {
image?: React.Node,
to?: string,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
|};
};
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref
) => {
const theme = useTheme();
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const compact = !subtitle;
const content = (selected) => (
<>
{image && <Image>{image}</Image>}
<Content
align={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content>
{actions && <Actions $selected={selected}>{actions}</Actions>}
</>
);
return (
<Wrapper
ref={ref}
$border={border}
activeStyle={{ background: theme.primary }}
{...rest}
as={to ? NavLink : undefined}
to={to}
>
{to ? content : content(false)}
<Wrapper compact={compact} $border={border}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
</Content>
{actions && <Actions>{actions}</Actions>}
</Wrapper>
);
};
const Wrapper = styled.div`
const Wrapper = styled.li`
display: flex;
user-select: none;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
@@ -78,36 +57,28 @@ const Image = styled(Flex)`
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
line-height: 1.2;
margin: 0;
`;
const Content = styled.div`
display: flex;
flex-direction: column;
const Content = styled(Flex)`
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
`;
export const Actions = styled(Flex)`
const Actions = styled.div`
align-self: center;
justify-content: center;
margin-right: 4px;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
export default ListItem;
+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;
@@ -0,0 +1,28 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import DelayedMount from "components/DelayedMount";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
export default function LoadingPlaceholder(props: Object) {
return (
<DelayedMount>
<Wrapper>
<Flex column auto {...props}>
<Mask height={34} />
<br />
<Mask />
<Mask />
<Mask />
</Flex>
</Wrapper>
</DelayedMount>
);
}
const Wrapper = styled(Fade)`
display: block;
margin: 40px 0;
`;
@@ -0,0 +1,6 @@
// @flow
import ListPlaceholder from "./ListPlaceholder";
import LoadingPlaceholder from "./LoadingPlaceholder";
export default LoadingPlaceholder;
export { ListPlaceholder };
+37 -21
View File
@@ -1,9 +1,35 @@
// @flow
import { format as formatDate, formatDistanceToNow } from "date-fns";
import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
fr,
es,
it,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
import { dateLocale } from "utils/i18n";
const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fr_FR: fr,
it_IT: it,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
};
let callbacks = [];
@@ -27,8 +53,6 @@ type Props = {
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
relative?: boolean,
format?: string,
};
function LocaleTime({
@@ -36,8 +60,6 @@ function LocaleTime({
children,
dateTime,
shorten,
format,
relative,
tooltipDelay,
}: Props) {
const userLocale = useUserLocale();
@@ -56,31 +78,25 @@ function LocaleTime({
};
}, []);
const locale = dateLocale(userLocale);
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
let content = formatDistanceToNow(Date.parse(dateTime), {
addSuffix,
locale,
locale: userLocale ? locales[userLocale] : undefined,
});
if (shorten) {
relativeContent = relativeContent
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
const tooltipContent = formatDate(
Date.parse(dateTime),
format || "MMMM do, yyyy h:mm a",
{ locale }
);
const content =
children || relative !== false ? relativeContent : tooltipContent;
return (
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{content}</time>
<Tooltip
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
}
@@ -2,48 +2,44 @@
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(props: Props) {
super();
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
}
render() {
return (
<Mask
width={this.width}
height={this.props.height}
delay={this.props.delay}
/>
);
return <Redacted width={this.width} height={this.props.height} />;
}
}
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;
-11
View File
@@ -1,11 +0,0 @@
// @flow
import styled from "styled-components";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
`;
export default MenuIconWrapper;
+2 -3
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;
@@ -61,8 +61,7 @@ const Modal = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScroll
hideOnEsc
preventBodyScrollhideOnEsc
hide={onRequestClose}
>
{(props) => (
-26
View File
@@ -1,26 +0,0 @@
// @flow
import * as React from "react";
import { NavLink, Route, type Match } from "react-router-dom";
type Props = {
children?: (match: Match) => React.Node,
exact?: boolean,
to: string,
};
export default function NavLinkWithChildrenFunc({
to,
exact = false,
children,
...rest
}: Props) {
return (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink {...rest} to={to} exact={exact}>
{children ? children(match) : null}
</NavLink>
)}
</Route>
);
}
-1
View File
@@ -12,7 +12,6 @@ const Button = styled.button`
padding: 0;
cursor: pointer;
user-select: none;
color: inherit;
`;
export default React.forwardRef<any, typeof Button>(
+1 -1
View File
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { cdnPath } from "../../shared/utils/urls";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = {|
title: string,
+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;
-53
View File
@@ -1,53 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import PaginatedList from "components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {|
events: Event[],
document: Document,
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
|};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
events,
fetch,
options,
document,
...rest
}: Props) {
return (
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
/>
);
});
const Heading = styled("h3")`
font-size: 14px;
padding: 0 12px;
`;
export default PaginatedEventList;
+7 -50
View File
@@ -2,15 +2,12 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Waypoint } from "react-waypoint";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
import { ListPlaceholder } from "components/LoadingPlaceholder";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@@ -18,10 +15,7 @@ type Props = {
heading?: React.Node,
empty?: React.Node,
items: any[],
auth: AuthStore,
renderItem: (any, index: number) => React.Node,
renderHeading?: (name: React.Element<any> | string) => React.Node,
t: TFunction,
renderItem: (any) => React.Node,
};
@observer
@@ -107,9 +101,8 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, auth, empty, renderHeading } = this.props;
const { items, heading, empty } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
@@ -126,41 +119,7 @@ class PaginatedList extends React.Component<Props> {
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
{items.slice(0, this.renderCount).map(this.props.renderItem)}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
@@ -169,7 +128,7 @@ class PaginatedList extends React.Component<Props> {
)}
{showLoading && (
<DelayedMount>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</DelayedMount>
)}
</>
@@ -177,6 +136,4 @@ class PaginatedList extends React.Component<Props> {
}
}
export const Component = PaginatedList;
export default withTranslation()<PaginatedList>(inject("auth")(PaginatedList));
export default PaginatedList;
+1 -1
View File
@@ -4,7 +4,7 @@ import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import { Component as PaginatedList } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
-56
View File
@@ -1,56 +0,0 @@
// @flow
import * as React from "react";
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";
export default function PlaceholderDocument({
includeTitle,
delay,
}: {
includeTitle?: boolean,
delay?: number,
}) {
const content = (
<>
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</>
);
if (includeTitle === false) {
return (
<DelayedMount delay={delay}>
<Fade>
<Flex column auto>
{content}
</Flex>
</Fade>
</DelayedMount>
);
}
return (
<DelayedMount delay={delay}>
<Wrapper>
<Fade>
<Flex column auto>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
{content}
</Flex>
</Fade>
</Wrapper>
</DelayedMount>
);
}
const Wrapper = styled(Fade)`
display: block;
margin: 40px 0;
`;
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "styles/animations";
import { fadeAndScaleIn } from "shared/styles/animations";
type Props = {
children: React.Node,
+45 -29
View File
@@ -1,10 +1,13 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
HomeIcon,
EditIcon,
SearchIcon,
StarredIcon,
ShapesIcon,
HomeIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
@@ -20,23 +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 Starred from "./components/Starred";
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 (
@@ -109,6 +108,23 @@ function MainSidebar() {
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
)}
{can.createDocument && (
<SidebarLink
to="/drafts"
@@ -129,32 +145,32 @@ function MainSidebar() {
/>
)}
</Section>
<Starred />
<Section auto>
<Collections
onCreateCollection={handleCreateCollectionModalOpen}
/>
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active
? documents.active.isTemplate &&
!documents.active.isDeleted &&
!documents.active.isArchived
: 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" />}
+1 -1
View File
@@ -6,13 +6,13 @@ import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeIn } from "shared/styles/animations";
import Fade from "components/Fade";
import Flex from "components/Flex";
import ResizeBorder from "./components/ResizeBorder";
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;
@@ -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);
@@ -3,7 +3,6 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
@@ -13,7 +12,6 @@ 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";
@@ -37,8 +35,7 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const { search } = useLocation();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -54,17 +51,12 @@ function CollectionLink({
);
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
// touch the expanded / collapsed state of the collections
if (search === "?starred") {
return;
}
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
@@ -166,20 +158,19 @@ function CollectionLink({
/>
}
exact={false}
depth={0.5}
menu={
<>
{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)}
/>
</>
}
@@ -206,7 +197,7 @@ function CollectionLink({
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={2}
depth={1.5}
index={index}
/>
))}
@@ -1,21 +1,19 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { PlusIcon, CollapsedIcon } from "outline-icons";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
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 PlaceholderCollections from "./PlaceholderCollections";
import Header from "./Header";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
type Props = {
onCreateCollection: () => void,
};
@@ -24,8 +22,6 @@ 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 [expanded, setExpanded] = React.useState(true);
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
@@ -42,7 +38,7 @@ function Collections({ onCreateCollection }: Props) {
setFetching(true);
await collections.fetchPage({ limit: 100 });
} catch (error) {
showToast(
ui.showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
@@ -55,7 +51,7 @@ function Collections({ onCreateCollection }: Props) {
}
}
load();
}, [collections, isFetching, showToast, fetchError, t]);
}, [collections, isFetching, ui, fetchError, t]);
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
@@ -100,7 +96,6 @@ function Collections({ onCreateCollection }: Props) {
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
depth={0.5}
/>
)}
</>
@@ -109,31 +104,18 @@ function Collections({ onCreateCollection }: Props) {
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<SidebarLink
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
disabled
/>
<PlaceholderCollections />
<Header>{t("Collections")}</Header>
<CollectionsLoading />
</Flex>
);
}
return (
<Flex column>
<SidebarLink
onClick={() => setExpanded((prev) => !prev)}
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
<Header>{t("Collections")}</Header>
{isPreloaded ? content : <Fade>{content}</Fade>}
</Flex>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Collections);
@@ -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;
@@ -1,13 +0,0 @@
// @flow
import { CollapsedIcon } from "outline-icons";
import styled from "styled-components";
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default Disclosure;
@@ -1,5 +1,6 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
@@ -7,12 +8,10 @@ import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import Fade from "components/Fade";
import Disclosure from "./Disclosure";
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";
@@ -121,7 +120,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 +132,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;
},
});
@@ -210,7 +205,7 @@ function DocumentLink(
return (
<>
<Relative onDragLeave={resetHoverExpanding}>
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
<Draggable
key={node.id}
ref={drag}
@@ -240,22 +235,18 @@ function DocumentLink(
/>
</>
}
isActive={(match, location) =>
match && location.search !== "?starred"
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
scrollIntoViewIfNeeded={!document?.isStarred}
ref={ref}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
@@ -267,7 +258,7 @@ function DocumentLink(
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
</div>
{expanded && !isDragging && (
<>
{node.children.map((childNode, index) => (
@@ -289,15 +280,19 @@ function DocumentLink(
);
}
const Relative = styled.div`
position: relative;
`;
const Draggable = styled.div`
const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;
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));
export default ObservedDocumentLink;
@@ -27,7 +27,7 @@ const Cursor = styled("div")`
width: 100%;
height: 14px;
${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")}
${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")}
background: transparent;
::after {
@@ -7,7 +7,6 @@ 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,
@@ -19,8 +18,7 @@ type Props = {|
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { ui, documents, policies } = useStores();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
@@ -29,11 +27,11 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const can = policies.abilities(collectionId);
const handleRejection = React.useCallback(() => {
showToast(
ui.showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, showToast]);
}, [t, ui]);
if (disabled || !can.update) {
return children;
@@ -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,33 +39,26 @@ 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 (
<>
+1 -2
View File
@@ -5,11 +5,10 @@ import Flex from "components/Flex";
const Header = styled(Flex)`
font-size: 11px;
font-weight: 600;
user-select: none;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 4px 12px;
margin: 4px 16px;
`;
export default Header;
+2 -4
View File
@@ -31,7 +31,6 @@ type Props = {|
activeClassName?: String,
activeStyle?: Object,
className?: string,
scrollIntoViewIfNeeded?: boolean,
exact?: boolean,
isActive?: any,
location?: Location,
@@ -53,7 +52,6 @@ const NavLink = ({
location: locationProp,
strict,
style: styleProp,
scrollIntoViewIfNeeded,
to,
...rest
}: Props) => {
@@ -85,13 +83,13 @@ const NavLink = ({
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
React.useEffect(() => {
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
if (isActive && linkRef.current) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "instant",
});
}
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
}, [linkRef, isActive]);
const props = {
"aria-current": (isActive && ariaCurrent) || null,
@@ -1,22 +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;
margin-left: 40px;
width: 75%;
`;
export default PlaceholderCollections;
+1 -1
View File
@@ -5,7 +5,7 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 0 8px 12px;
margin: 0 8px 20px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
@@ -8,7 +8,7 @@ import EventBoundary from "components/EventBoundary";
import NavLink from "./NavLink";
import { type Theme } from "types";
type Props = {|
type Props = {
to?: string | Object,
href?: string | Object,
innerRef?: (?HTMLElement) => void,
@@ -28,8 +28,7 @@ type Props = {|
theme: Theme,
exact?: boolean,
depth?: number,
scrollIntoViewIfNeeded?: boolean,
|};
};
function SidebarLink(
{
@@ -50,14 +49,12 @@ function SidebarLink(
history,
match,
className,
scrollIntoViewIfNeeded,
...rest
}: Props,
ref
) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 12}px`,
paddingLeft: `${(depth || 0) * 16 + 16}px`,
};
}, [depth]);
@@ -76,7 +73,6 @@ function SidebarLink(
<>
<Link
$isActiveDrop={isActiveDrop}
scrollIntoViewIfNeeded={scrollIntoViewIfNeeded}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
@@ -87,7 +83,6 @@ function SidebarLink(
href={href}
className={className}
ref={ref}
{...rest}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -136,7 +131,6 @@ const Link = styled(NavLink)`
padding: 6px 16px;
border-radius: 4px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
@@ -162,11 +156,13 @@ const Link = styled(NavLink)`
`}
@media (hover: hover) {
&:hover + ${Actions}, &:active + ${Actions} {
display: inline-flex;
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
svg {
opacity: 0.75;
}
}
}
@@ -1,171 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "components/Flex";
import PlaceholderCollections from "./PlaceholderCollections";
import Section from "./Section";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
const STARRED_PAGINATION_LIMIT = 10;
const STARRED = "STARRED";
function Starred() {
const [isFetching, setIsFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const [expanded, setExpanded] = React.useState(true);
const [show, setShow] = React.useState("Nothing");
const [offset, setOffset] = React.useState(0);
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
const { showToast } = useToasts();
const { documents } = useStores();
const { t } = useTranslation();
const { fetchStarred, starred } = documents;
const fetchResults = React.useCallback(async () => {
try {
setIsFetching(true);
await fetchStarred({
limit: STARRED_PAGINATION_LIMIT,
offset,
});
} catch (error) {
showToast(t("Starred documents could not be loaded"), {
type: "error",
});
setFetchError(error);
} finally {
setIsFetching(false);
}
}, [fetchStarred, offset, showToast, t]);
useEffect(() => {
let stateInLocal;
try {
stateInLocal = localStorage.getItem(STARRED);
} catch (_) {
// no-op Safari private mode
}
if (!stateInLocal) {
localStorage.setItem(STARRED, expanded ? "true" : "false");
} else {
setExpanded(stateInLocal === "true");
}
}, [expanded]);
useEffect(() => {
setOffset(starred.length);
if (starred.length <= STARRED_PAGINATION_LIMIT) {
setShow("Nothing");
} else if (starred.length >= upperBound) {
setShow("More");
} else if (starred.length < upperBound) {
setShow("Less");
}
}, [starred, upperBound]);
useEffect(() => {
if (offset === 0) {
fetchResults();
}
}, [fetchResults, offset]);
const handleShowMore = React.useCallback(
async (ev) => {
setUpperBound(
(previousUpperBound) => previousUpperBound + STARRED_PAGINATION_LIMIT
);
await fetchResults();
},
[fetchResults]
);
const handleShowLess = React.useCallback((ev) => {
setUpperBound(STARRED_PAGINATION_LIMIT);
setShow("More");
}, []);
const handleExpandClick = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
localStorage.setItem(STARRED, !expanded ? "true" : "false");
} catch (_) {
// no-op Safari private mode
}
setExpanded((prev) => !prev);
},
[expanded]
);
const content = starred.slice(0, upperBound).map((document, index) => {
return (
<StarredLink
key={document.id}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
url={document.url}
depth={2}
/>
);
});
if (!starred.length) {
return null;
}
return (
<Section>
<Flex column>
<SidebarLink
onClick={handleExpandClick}
label={t("Starred")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (
<>
{content}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={2}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={2}
/>
)}
{(isFetching || fetchError) && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</>
)}
</Flex>
</Section>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Starred);
@@ -1,127 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Fade from "components/Fade";
import useStores from "../../../hooks/useStores";
import Disclosure from "./Disclosure";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import DocumentMenu from "menus/DocumentMenu";
type Props = {|
depth: number,
title: string,
to: string,
documentId: string,
collectionId: string,
|};
function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
const { t } = useTranslation();
const { collections, documents, policies } = useStores();
const collection = collections.get(collectionId);
const document = documents.get(documentId);
const [expanded, setExpanded] = useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const canUpdate = policies.abilities(documentId).update;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
useEffect(() => {
async function load() {
if (!document) {
await documents.fetch(documentId);
}
}
load();
}, [collection, collectionId, collections, document, documentId, documents]);
const handleDisclosureClick = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
}, []);
const handleTitleChange = React.useCallback(
async (title: string) => {
if (!document) return;
await documents.update({
id: document.id,
lastRevision: document.revision,
text: document.text,
title,
});
},
[documents, document]
);
return (
<>
<Relative>
<SidebarLink
depth={depth}
to={`${to}?starred`}
isActive={(match, location) =>
match && location.search === "?starred"
}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
exact={false}
showActions={menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Relative>
{expanded &&
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
collectionId={collectionId}
/>
))}
</>
);
}
const Relative = styled.div`
position: relative;
`;
const ObserveredStarredLink = observer(StarredLink);
export default ObserveredStarredLink;
@@ -1,5 +1,4 @@
// @flow
import { observer } from "mobx-react";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -85,4 +84,4 @@ const Header = styled.button`
}
`;
export default observer(TeamButton);
export default TeamButton;
@@ -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);
+6 -25
View File
@@ -8,11 +8,10 @@ import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import DocumentsStore from "stores/DocumentsStore";
import FileOperationsStore from "stores/FileOperationsStore";
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";
@@ -28,8 +27,7 @@ type Props = {
policies: PoliciesStore,
views: ViewsStore,
auth: AuthStore,
toasts: ToastsStore,
fileOperations: FileOperationsStore,
ui: UiStore,
};
@observer
@@ -74,7 +72,7 @@ class SocketProvider extends React.Component<Props> {
const {
auth,
toasts,
ui,
documents,
collections,
groups,
@@ -82,7 +80,6 @@ class SocketProvider extends React.Component<Props> {
policies,
presence,
views,
fileOperations,
} = this.props;
if (!auth.token) return;
@@ -116,7 +113,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;
@@ -290,21 +287,6 @@ class SocketProvider extends React.Component<Props> {
}
});
this.socket.on("fileOperations.update", async (event) => {
const user = auth.user;
let collection = null;
if (event.collectionId)
collection = await collections.fetch(event.collectionId);
if (user) {
fileOperations.add({
...event,
user,
collection,
});
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event) => {
@@ -356,13 +338,12 @@ class SocketProvider extends React.Component<Props> {
export default inject(
"auth",
"toasts",
"ui",
"documents",
"collections",
"groups",
"memberships",
"presence",
"policies",
"views",
"fileOperations"
"views"
)(SocketProvider);
+13 -41
View File
@@ -1,14 +1,14 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import NavLinkWithChildrenFunc from "components/NavLink";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
children: React.Node,
theme: Theme,
};
const TabLink = styled(NavLinkWithChildrenFunc)`
const TabLink = styled(NavLink)`
position: relative;
display: inline-flex;
align-items: center;
@@ -20,47 +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,
};
export default function Tab({ children, ...rest }: Props) {
const theme = useTheme();
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);
+18 -57
View File
@@ -1,5 +1,4 @@
// @flow
import { isEqual } from "lodash";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
@@ -9,7 +8,7 @@ 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";
import Mask from "components/Mask";
export type Props = {|
data: any[],
@@ -25,7 +24,6 @@ export type Props = {|
onChangePage: (index: number) => void,
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
columns: any,
defaultSortDirection: "ASC" | "DESC",
|};
function Table({
@@ -41,7 +39,6 @@ function Table({
topRef,
onChangeSort,
onChangePage,
defaultSortDirection,
}: Props) {
const { t } = useTranslation();
const {
@@ -65,52 +62,32 @@ function Table({
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [
{
id: defaultSort,
desc: defaultSortDirection === "DESC" ? true : false,
},
],
sortBy: [{ id: defaultSort, desc: false }],
pageSize,
pageIndex: page,
},
stateReducer: (newState, action, prevState) => {
if (!isEqual(newState.sortBy, prevState.sortBy)) {
return { ...newState, pageIndex: 0 };
}
return newState;
},
},
useSortBy,
usePagination
);
const prevSortBy = React.useRef(sortBy);
React.useEffect(() => {
onChangePage(pageIndex);
}, [pageIndex]);
React.useEffect(() => {
if (!isEqual(sortBy, prevSortBy.current)) {
prevSortBy.current = sortBy;
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
);
}
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
const handleNextPage = () => {
nextPage();
onChangePage(pageIndex + 1);
};
const handlePreviousPage = () => {
previousPage();
onChangePage(pageIndex - 1);
};
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} />
@@ -165,12 +142,12 @@ function Table({
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={handlePreviousPage} neutral>
<Button onClick={previousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={handleNextPage} neutral>
<Button onClick={nextPage} neutral>
{t("Next page")}
</Button>
)}
@@ -193,7 +170,7 @@ export const Placeholder = ({
<Row key={row}>
{new Array(columns).fill().map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
<Mask minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
@@ -232,7 +209,7 @@ const SortWrapper = styled(Flex)`
`;
const Cell = styled.td`
padding: 6px;
padding: 8px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
@@ -249,14 +226,6 @@ const Cell = styled.td`
`;
const Row = styled.tr`
${Cell} {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
&:last-child {
${Cell} {
border-bottom: 0;
@@ -268,7 +237,7 @@ const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px;
padding: 6px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
@@ -276,14 +245,6 @@ const Head = styled.th`
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
z-index: 1;
:first-child {
padding-left: 0;
}
:last-child {
padding-right: 0;
}
`;
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>
);
};
+2 -2
View File
@@ -2,10 +2,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { ThemeProvider } from "styled-components";
import { dark, light, lightMobile, darkMobile } from "shared/theme";
import GlobalStyles from "shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "shared/styles/theme";
import useMediaQuery from "hooks/useMediaQuery";
import useStores from "hooks/useStores";
import GlobalStyles from "styles/globals";
const empty = {};
+1 -2
View File
@@ -11,7 +11,6 @@ type Props = {
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
format?: string,
shorten?: boolean,
};
@@ -33,7 +32,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>

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