mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce65f27a47 | |||
| a800fe4fd1 | |||
| 13d5bc281b | |||
| 09b73401de | |||
| 42b384688d | |||
| 5bdee1204e | |||
| 9db72217af | |||
| 57a2524fbd | |||
| bd148f4790 | |||
| 28d32af613 | |||
| f2f550e1d2 | |||
| dad21b2186 | |||
| 5fb5f1e8b5 | |||
| 7bbbfa6bbf | |||
| 2d0690697c | |||
| 6b551749d4 | |||
| 52fc861bcf | |||
| c81c9a9d2d | |||
| 29c742a673 | |||
| dd249021e7 | |||
| 21d3b9c7e0 | |||
| 6665dfff28 | |||
| cdfe3a7fc3 | |||
| 401c91f90b | |||
| ed5320507d | |||
| f1f8badc8f | |||
| a6b43abed1 |
+94
-2
@@ -1,4 +1,12 @@
|
||||
version: 2
|
||||
version: 2.1
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
environment:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:stretch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
@@ -40,4 +48,88 @@ jobs:
|
||||
command: yarn test
|
||||
- run:
|
||||
name: build-webpack
|
||||
command: yarn 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: /.*/
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
.*/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/lib0/.*
|
||||
.*/server/scripts/.*
|
||||
*.test.js
|
||||
|
||||
|
||||
+40
-13
@@ -1,23 +1,50 @@
|
||||
FROM node:14-alpine
|
||||
# syntax=docker/dockerfile:1.2
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:14-alpine AS deps-common
|
||||
|
||||
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
|
||||
|
||||
# ---
|
||||
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
|
||||
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
RUN yarn --pure-lockfile
|
||||
|
||||
COPY . .
|
||||
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
|
||||
RUN yarn build
|
||||
|
||||
RUN yarn build && \
|
||||
yarn --production --ignore-scripts --prefer-offline && \
|
||||
rm -rf shared && \
|
||||
rm -rf app
|
||||
# ---
|
||||
FROM node:14-alpine AS runner
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV production
|
||||
CMD yarn start
|
||||
|
||||
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
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
@@ -66,7 +66,11 @@ const RealButton = styled.button`
|
||||
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
background: ${
|
||||
props.borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
: 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;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// @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);
|
||||
@@ -1,199 +0,0 @@
|
||||
// @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 PlaceholderList from "components/List/Placeholder";
|
||||
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 we’re 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>
|
||||
<PlaceholderList 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);
|
||||
@@ -1,87 +0,0 @@
|
||||
// @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);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentHistory from "./DocumentHistory";
|
||||
export default DocumentHistory;
|
||||
@@ -66,6 +66,7 @@ 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
|
||||
@@ -126,7 +127,8 @@ function DocumentListItem(props: Props, ref) {
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument && (
|
||||
can.createDocument &&
|
||||
canCollection.update && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
|
||||
@@ -246,6 +246,50 @@ 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 }) => (
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// @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;
|
||||
+52
-23
@@ -1,41 +1,62 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } 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,
|
||||
}: Props) => {
|
||||
const ListItem = (
|
||||
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
|
||||
ref
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const compact = !subtitle;
|
||||
|
||||
return (
|
||||
<Wrapper compact={compact} $border={border}>
|
||||
const content = (selected) => (
|
||||
<>
|
||||
{image && <Image>{image}</Image>}
|
||||
<Content align={compact ? "center" : undefined} column={!compact}>
|
||||
<Content
|
||||
align={compact ? "center" : undefined}
|
||||
column={!compact}
|
||||
$selected={selected}
|
||||
>
|
||||
<Heading $small={small}>{title}</Heading>
|
||||
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
|
||||
{subtitle && (
|
||||
<Subtitle $small={small} $selected={selected}>
|
||||
{subtitle}
|
||||
</Subtitle>
|
||||
)}
|
||||
</Content>
|
||||
{actions && <Actions>{actions}</Actions>}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.li`
|
||||
const Wrapper = styled.div`
|
||||
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
|
||||
@@ -57,28 +78,36 @@ const Image = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Heading = styled.p`
|
||||
font-size: ${(props) => (props.$small ? 15 : 16)}px;
|
||||
font-size: ${(props) => (props.$small ? 14 : 16)}px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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.theme.textTertiary};
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
export const Actions = styled(Flex)`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
export default ListItem;
|
||||
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
enUS,
|
||||
de,
|
||||
@@ -57,6 +57,9 @@ type Props = {
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
shorten?: boolean,
|
||||
relative?: boolean,
|
||||
format?: string,
|
||||
tooltip?: boolean,
|
||||
};
|
||||
|
||||
function LocaleTime({
|
||||
@@ -64,7 +67,10 @@ function LocaleTime({
|
||||
children,
|
||||
dateTime,
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
tooltipDelay,
|
||||
tooltip,
|
||||
}: Props) {
|
||||
const userLocale = useUserLocale();
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
|
||||
@@ -82,25 +88,34 @@ function LocaleTime({
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = formatDistanceToNow(Date.parse(dateTime), {
|
||||
const locale = userLocale ? locales[userLocale] : undefined;
|
||||
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
locale,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
relativeContent = relativeContent
|
||||
.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 ? relativeContent : tooltipContent;
|
||||
|
||||
if (!tooltip) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
|
||||
<time dateTime={dateTime}>{content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const Button = styled.button`
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<any, typeof Button>(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @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;
|
||||
@@ -4,10 +4,12 @@ import { isEqual } from "lodash";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import PlaceholderList from "components/List/Placeholder";
|
||||
import { dateToHeading } from "utils/dates";
|
||||
|
||||
type Props = {
|
||||
fetch?: (options: ?Object) => Promise<void>,
|
||||
@@ -15,7 +17,9 @@ type Props = {
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
items: any[],
|
||||
renderItem: (any) => React.Node,
|
||||
renderItem: (any, index: number) => React.Node,
|
||||
renderHeading?: (name: React.Element<any> | string) => React.Node,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -101,8 +105,9 @@ class PaginatedList extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { items, heading, empty } = this.props;
|
||||
const { items, heading, empty, renderHeading } = this.props;
|
||||
|
||||
let previousHeading = "";
|
||||
const showLoading =
|
||||
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
||||
const showEmpty = !items.length && !showLoading;
|
||||
@@ -119,7 +124,37 @@ class PaginatedList extends React.Component<Props> {
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.slice(0, this.renderCount).map(this.props.renderItem)}
|
||||
{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);
|
||||
|
||||
// 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;
|
||||
})}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
@@ -136,4 +171,6 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default PaginatedList;
|
||||
export const Component = PaginatedList;
|
||||
|
||||
export default withTranslation()<PaginatedList>(PaginatedList);
|
||||
|
||||
@@ -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 PaginatedList from "./PaginatedList";
|
||||
import { Component as PaginatedList } from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const render = () => null;
|
||||
|
||||
@@ -149,7 +149,11 @@ function MainSidebar() {
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
documents.active
|
||||
? documents.active.isTemplate &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isArchived
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ArchiveLink documents={documents} />
|
||||
|
||||
+4
-17
@@ -1,25 +1,13 @@
|
||||
// @flow
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { NavLink, Route } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import { type Theme } from "types";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import NavLinkWithChildrenFunc from "components/NavLink";
|
||||
|
||||
type Props = {
|
||||
theme: Theme,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
|
||||
<Route path={to} exact={exact}>
|
||||
{({ match }) => (
|
||||
<NavLink to={to} exact={exact} {...rest}>
|
||||
{children(match)}
|
||||
</NavLink>
|
||||
)}
|
||||
</Route>
|
||||
);
|
||||
|
||||
const TabLink = styled(NavLinkWithChildrenFunc)`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -53,7 +41,8 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
function Tab({ theme, children, ...rest }: Props) {
|
||||
export default function Tab({ children, ...rest }: Props) {
|
||||
const theme = useTheme();
|
||||
const activeStyle = {
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
@@ -75,5 +64,3 @@ function Tab({ theme, children, ...rest }: Props) {
|
||||
</TabLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(Tab);
|
||||
|
||||
@@ -11,6 +11,7 @@ type Props = {
|
||||
children?: React.Node,
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
format?: string,
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing$"
|
||||
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing(.*)"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
@@ -29,7 +29,7 @@ export default class GoogleDrive extends React.Component<Props> {
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
title="Google Drive Embed"
|
||||
title="Google Drive"
|
||||
canonicalUrl={this.props.attrs.href}
|
||||
border
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import GoogleDrive from "./GoogleDrive";
|
||||
|
||||
describe("GoogleDrive", () => {
|
||||
const match = GoogleDrive.ENABLED[0];
|
||||
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
|
||||
@@ -14,6 +15,11 @@ describe("GoogleDrive", () => {
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing&resourceKey=BG8k4dEt1p2gisnVdlaSpA".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
|
||||
+24
-16
@@ -139,6 +139,27 @@ function DocumentMenu({
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = policies.abilities(document.id);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
...collections.orderedData.reduce((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
if (can.update) {
|
||||
filtered.push({
|
||||
onClick: (ev) => handleRestore(ev, { collectionId: collection.id }),
|
||||
title: (
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, []),
|
||||
],
|
||||
[collections.orderedData, handleRestore, policies]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.stopPropagation();
|
||||
@@ -230,7 +251,8 @@ function DocumentMenu({
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !collection && !!can.restore,
|
||||
visible:
|
||||
!collection && !!can.restore && restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
@@ -242,21 +264,7 @@ function DocumentMenu({
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return {
|
||||
title: (
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
onClick: (ev) =>
|
||||
handleRestore(ev, { collectionId: collection.id }),
|
||||
disabled: !can.update,
|
||||
};
|
||||
}),
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,20 +21,36 @@ function NewDocumentMenu() {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const singleCollection = collections.orderedData.length === 1;
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
if (can.update) {
|
||||
filtered.push({
|
||||
to: newDocumentUrl(collection.id),
|
||||
title: (
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, []),
|
||||
[collections.orderedData, policies]
|
||||
);
|
||||
|
||||
if (!can.createDocument || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (singleCollection) {
|
||||
if (items.length === 1) {
|
||||
return (
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(collections.orderedData[0].id)}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
<Button as={Link} to={items[0].to} icon={<PlusIcon />}>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
);
|
||||
@@ -51,19 +67,7 @@ function NewDocumentMenu() {
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("New document")}>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<Template
|
||||
{...menu}
|
||||
items={collections.orderedData.map((collection) => ({
|
||||
to: newDocumentUrl(collection.id),
|
||||
disabled: !policies.abilities(collection.id).update,
|
||||
title: (
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,27 @@ function NewTemplateMenu() {
|
||||
const { collections, policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
if (can.update) {
|
||||
filtered.push({
|
||||
to: newDocumentUrl(collection.id, { template: true }),
|
||||
title: (
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, []),
|
||||
[collections.orderedData, policies]
|
||||
);
|
||||
|
||||
if (!can.createDocument || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -37,21 +57,7 @@ function NewTemplateMenu() {
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={t("New template")} {...menu}>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<Template
|
||||
{...menu}
|
||||
items={collections.orderedData.map((collection) => ({
|
||||
to: newDocumentUrl(collection.id, {
|
||||
template: true,
|
||||
}),
|
||||
disabled: !policies.abilities(collection.id).update,
|
||||
title: (
|
||||
<Flex align="center">
|
||||
<CollectionIcon collection={collection} />
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
</Flex>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
@@ -16,12 +15,11 @@ import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
iconColor?: string,
|
||||
revisionId: string,
|
||||
className?: string,
|
||||
|};
|
||||
|
||||
function RevisionMenu({ document, revision, className, iconColor }: Props) {
|
||||
function RevisionMenu({ document, revisionId, className }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { t } = useTranslation();
|
||||
@@ -30,11 +28,11 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
|
||||
const handleRestore = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
await document.restore({ revisionId: revision.id });
|
||||
await document.restore({ revisionId });
|
||||
showToast(t("Document restored"), { type: "success" });
|
||||
history.push(document.url);
|
||||
},
|
||||
[history, showToast, t, document, revision]
|
||||
[history, showToast, t, document, revisionId]
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
@@ -43,14 +41,14 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
|
||||
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
document,
|
||||
revision.id
|
||||
revisionId
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
iconColor={iconColor}
|
||||
iconColor="currentColor"
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
/>
|
||||
|
||||
@@ -261,6 +261,27 @@ export default class Document extends BaseModel {
|
||||
this.injectTemplate = true;
|
||||
};
|
||||
|
||||
@action
|
||||
update = async (options: SaveOptions & { title: string }) => {
|
||||
if (this.isSaving) return this;
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
if (options.lastRevision) {
|
||||
return await this.store.update({
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
lastRevision: options.lastRevision,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("Attempting to update without a lastRevision");
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
save = async (options: SaveOptions = {}) => {
|
||||
if (this.isSaving) return this;
|
||||
|
||||
@@ -20,20 +20,6 @@ class Event extends BaseModel {
|
||||
published: boolean,
|
||||
templateId: string,
|
||||
};
|
||||
|
||||
get model() {
|
||||
return this.name.split(".")[0];
|
||||
}
|
||||
|
||||
get verb() {
|
||||
return this.name.split(".")[1];
|
||||
}
|
||||
|
||||
get verbPastTense() {
|
||||
const v = this.verb;
|
||||
if (v.endsWith("e")) return `${v}d`;
|
||||
return `${v}ed`;
|
||||
}
|
||||
}
|
||||
|
||||
export default Event;
|
||||
|
||||
@@ -7,6 +7,7 @@ class Team extends BaseModel {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
sharing: boolean;
|
||||
multiplayerEditor: boolean;
|
||||
documentEmbeds: boolean;
|
||||
guestSignin: boolean;
|
||||
subdomain: ?string;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @flow
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { Extension } from "rich-markdown-editor";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export default class MultiplayerExtension extends Extension {
|
||||
get name() {
|
||||
return "multiplayer";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const { user, provider, document: doc } = this.options;
|
||||
const type = doc.get("prosemirror", Y.XmlFragment);
|
||||
|
||||
const assignUser = (tr) => {
|
||||
const clientIds = Array.from(doc.store.clients.keys());
|
||||
|
||||
if (
|
||||
tr.local &&
|
||||
tr.changed.size > 0 &&
|
||||
!clientIds.includes(doc.clientID)
|
||||
) {
|
||||
const permanentUserData = new Y.PermanentUserData(doc);
|
||||
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
|
||||
doc.off("afterTransaction", assignUser);
|
||||
}
|
||||
};
|
||||
|
||||
provider.awareness.setLocalStateField("user", {
|
||||
color: user.color,
|
||||
name: user.name,
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
doc.on("afterTransaction", assignUser);
|
||||
|
||||
return [
|
||||
ySyncPlugin(type),
|
||||
yCursorPlugin(provider.awareness),
|
||||
yUndoPlugin(),
|
||||
keymap({
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Mod-Shift-z": redo,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
const SettingsRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "settings" */ "./settings")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
const Document = React.lazy(() =>
|
||||
import(/* webpackChunkName: "document" */ "scenes/Document")
|
||||
);
|
||||
const NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
@@ -66,10 +64,10 @@ export default function AuthenticatedRoutes() {
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
|
||||
+4
-6
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
|
||||
const AuthenticatedRoutes = React.lazy(() =>
|
||||
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
|
||||
);
|
||||
const KeyedDocument = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
|
||||
)
|
||||
const SharedDocument = React.lazy(() =>
|
||||
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
|
||||
);
|
||||
const Login = React.lazy(() =>
|
||||
import(/* webpackChunkName: "login" */ "scenes/Login")
|
||||
@@ -37,11 +35,11 @@ export default function Routes() {
|
||||
<Route exact path="/" component={Login} />
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||
<Route exact path="/share/:shareId" component={SharedDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/share/:shareId/doc/${slug}`}
|
||||
component={KeyedDocument}
|
||||
component={SharedDocument}
|
||||
/>
|
||||
<Authenticated>
|
||||
<AuthenticatedRoutes />
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
|
||||
class KeyedDocument extends React.Component<*> {
|
||||
componentWillUnmount() {
|
||||
this.props.ui.clearActiveDocument();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { documentSlug, revisionId } = this.props.match.params;
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
// for the key.
|
||||
const urlParts = documentSlug ? documentSlug.split("-") : [];
|
||||
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
|
||||
|
||||
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("ui")(KeyedDocument);
|
||||
@@ -0,0 +1,61 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type Match } from "react-router-dom";
|
||||
import { useTheme } from "styled-components";
|
||||
import Error404 from "scenes/Error404";
|
||||
import ErrorOffline from "scenes/ErrorOffline";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import Document from "./components/Document";
|
||||
import Loading from "./components/Loading";
|
||||
import { type LocationWithState } from "types";
|
||||
import { OfflineError } from "utils/errors";
|
||||
|
||||
type Props = {|
|
||||
match: Match,
|
||||
location: LocationWithState,
|
||||
|};
|
||||
|
||||
export default function SharedDocumentScene(props: Props) {
|
||||
const theme = useTheme();
|
||||
const [response, setResponse] = React.useState();
|
||||
const [error, setError] = React.useState<?Error>();
|
||||
const { documents } = useStores();
|
||||
const { shareId, documentSlug } = props.match.params;
|
||||
|
||||
// ensure the wider page color always matches the theme
|
||||
React.useEffect(() => {
|
||||
window.document.body.style.background = theme.background;
|
||||
}, [theme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const response = await documents.fetch(documentSlug, {
|
||||
shareId,
|
||||
});
|
||||
setResponse(response);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [documents, documentSlug, shareId]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return <Loading location={props.location} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Document
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
location={props.location}
|
||||
shareId={shareId}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,16 +18,15 @@ import Document from "models/Document";
|
||||
import Revision from "models/Revision";
|
||||
import Error404 from "scenes/Error404";
|
||||
import ErrorOffline from "scenes/ErrorOffline";
|
||||
import DocumentComponent from "./Document";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState, type NavigationNode } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
type Props = {|
|
||||
match: Match,
|
||||
auth: AuthStore,
|
||||
location: LocationWithState,
|
||||
shares: SharesStore,
|
||||
documents: DocumentsStore,
|
||||
@@ -36,6 +35,7 @@ type Props = {|
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
history: RouterHistory,
|
||||
children: (any) => React.Node,
|
||||
|};
|
||||
|
||||
const sharedTreeCache = {};
|
||||
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { location, policies, ui } = this.props;
|
||||
const { location, policies, auth, ui } = this.props;
|
||||
|
||||
if (this.error) {
|
||||
return this.error instanceof OfflineError ? (
|
||||
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
const team = auth.team;
|
||||
const document = this.document;
|
||||
const revision = this.revision;
|
||||
|
||||
if (!document) {
|
||||
if (!document || !team) {
|
||||
return (
|
||||
<>
|
||||
<Loading location={location} />
|
||||
@@ -247,20 +248,26 @@ class DataLoader extends React.Component<Props> {
|
||||
|
||||
const abilities = policies.abilities(document.id);
|
||||
|
||||
const key = team.multiplayerEditor
|
||||
? ""
|
||||
: this.isEditing
|
||||
? "editing"
|
||||
: "read-only";
|
||||
|
||||
return (
|
||||
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
|
||||
<React.Fragment key={key}>
|
||||
{this.isEditing && <HideSidebar ui={ui} />}
|
||||
<DocumentComponent
|
||||
document={document}
|
||||
revision={revision}
|
||||
abilities={abilities}
|
||||
location={location}
|
||||
readOnly={!this.isEditing || !abilities.update || document.isArchived}
|
||||
onSearchLink={this.onSearchLink}
|
||||
onCreateLink={this.onCreateLink}
|
||||
sharedTree={this.sharedTree}
|
||||
/>
|
||||
</SocketPresence>
|
||||
{this.props.children({
|
||||
document,
|
||||
revision,
|
||||
abilities,
|
||||
isEditing: this.isEditing,
|
||||
readOnly: !this.isEditing || !abilities.update || document.isArchived,
|
||||
onSearchLink: this.onSearchLink,
|
||||
onCreateLink: this.onCreateLink,
|
||||
sharedTree: this.sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
autosave?: boolean,
|
||||
} = {}
|
||||
) => {
|
||||
const { document } = this.props;
|
||||
const { document, auth } = this.props;
|
||||
|
||||
// prevent saves when we are already saving
|
||||
if (document.isSaving) return;
|
||||
@@ -227,10 +227,22 @@ class DocumentScene extends React.Component<Props> {
|
||||
document.tasks = getTasks(document.text);
|
||||
|
||||
try {
|
||||
const savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
let savedDocument = document;
|
||||
if (auth.team && auth.team.multiplayerEditor) {
|
||||
// update does not send "text" field to the API, this is a workaround
|
||||
// while the multiplayer editor is toggleable. Once it's finalized
|
||||
// this can be cleaned up to single code path
|
||||
savedDocument = await document.update({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
} else {
|
||||
savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
}
|
||||
|
||||
this.isDirty = false;
|
||||
this.lastRevision = savedDocument.revision;
|
||||
|
||||
@@ -275,6 +287,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
};
|
||||
|
||||
onChange = (getEditorText) => {
|
||||
const { auth } = this.props;
|
||||
if (auth.team?.multiplayerEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.getEditorText = getEditorText;
|
||||
|
||||
// document change while read only is presumed to be a checkbox edit,
|
||||
@@ -332,7 +349,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
auto
|
||||
>
|
||||
<Route
|
||||
path={`${match.url}/move`}
|
||||
path={`${document.url}/move`}
|
||||
component={() => (
|
||||
<Modal
|
||||
title={`Move ${document.noun}`}
|
||||
@@ -356,7 +373,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Prompt
|
||||
when={this.isDirty && !this.isUploading}
|
||||
when={
|
||||
this.isDirty &&
|
||||
!this.isUploading &&
|
||||
!team?.multiplayerEditor
|
||||
}
|
||||
message={t(
|
||||
`You have unsaved changes.\nAre you sure you want to discard them?`
|
||||
)}
|
||||
@@ -444,6 +465,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Editor
|
||||
id={document.id}
|
||||
innerRef={this.editor}
|
||||
multiplayer={team?.multiplayerEditor}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
|
||||
@@ -17,6 +17,7 @@ import Editor, { type Props as EditorProps } from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import HoverPreview from "components/HoverPreview";
|
||||
import Star, { AnimatedStar } from "components/Star";
|
||||
import MultiplayerEditor from "./MultiplayerEditor";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
@@ -27,6 +28,7 @@ type Props = {|
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
shareId: ?string,
|
||||
multiplayer?: boolean,
|
||||
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
innerRef: { current: any },
|
||||
children: React.Node,
|
||||
@@ -107,10 +109,12 @@ class DocumentEditor extends React.Component<Props> {
|
||||
innerRef,
|
||||
children,
|
||||
policies,
|
||||
multiplayer,
|
||||
t,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
const can = policies.abilities(document.id);
|
||||
const { emoji } = parseTitle(title);
|
||||
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
|
||||
@@ -162,7 +166,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
<EditorComponent
|
||||
ref={innerRef}
|
||||
autoFocus={!!title && !this.props.defaultValue}
|
||||
placeholder={t("…the rest is up to you")}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @flow
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import * as React from "react";
|
||||
import * as Y from "yjs";
|
||||
import Editor from "components/Editor";
|
||||
import env from "env";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
|
||||
|
||||
// TODO: typing
|
||||
export default function MultiplayerEditor(props: any) {
|
||||
const user = useCurrentUser();
|
||||
|
||||
// TODO
|
||||
//const [showCachedDocument, setShowCachedDocument] = React.useState(true);
|
||||
|
||||
// React.useEffect(() => {
|
||||
// if (isRemoteSynced) {
|
||||
// setTimeout(() => setShowCachedDocument(false), 100);
|
||||
// }
|
||||
// }, [showCachedDocument, isRemoteSynced]);
|
||||
|
||||
const extensions = React.useMemo(() => {
|
||||
console.log("extensions");
|
||||
|
||||
const ydoc = new Y.Doc();
|
||||
const provider = new HocuspocusProvider({
|
||||
url: env.MULTIPLAYER_URL,
|
||||
|
||||
// TODO: pipe documentId
|
||||
name: "example-document",
|
||||
document: ydoc,
|
||||
});
|
||||
|
||||
return [
|
||||
new MultiplayerExtension({
|
||||
user,
|
||||
provider,
|
||||
document: ydoc,
|
||||
}),
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<span style={{ position: "relative" }}>
|
||||
{true && (
|
||||
<Editor
|
||||
{...props}
|
||||
key="multiplayer"
|
||||
defaultValue={undefined}
|
||||
value={undefined}
|
||||
extensions={extensions}
|
||||
style={{ position: "absolute", width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
{/* {showCachedDocument && (
|
||||
<Editor
|
||||
{...props}
|
||||
style={{ position: "absolute", width: "100%" }}
|
||||
readOnly
|
||||
/>
|
||||
)} */}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,61 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { type Match } from "react-router-dom";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
export default DataLoader;
|
||||
import Document from "./components/Document";
|
||||
import SocketPresence from "./components/SocketPresence";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import { type LocationWithState } from "types";
|
||||
|
||||
type Props = {|
|
||||
location: LocationWithState,
|
||||
match: Match,
|
||||
|};
|
||||
|
||||
export default function DocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => ui.clearActiveDocument();
|
||||
}, [ui]);
|
||||
|
||||
const { documentSlug, revisionId } = props.match.params;
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
// for the key.
|
||||
const urlParts = documentSlug ? documentSlug.split("-") : [];
|
||||
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
|
||||
const key = [urlId, revisionId].join("/");
|
||||
const isMultiplayer = team.multiplayerEditor;
|
||||
|
||||
return (
|
||||
<DataLoader key={key} match={props.match}>
|
||||
{({ document, isEditing, ...rest }) => {
|
||||
const isActive =
|
||||
!document.isArchived && !document.isDeleted && !revisionId;
|
||||
|
||||
// TODO: Remove once multiplayer is 100% rollout, SocketPresence will
|
||||
// no longer be required
|
||||
if (isActive && !isMultiplayer) {
|
||||
return (
|
||||
<SocketPresence
|
||||
documentId={document.id}
|
||||
userId={user.id}
|
||||
isEditing={isEditing}
|
||||
>
|
||||
<Document document={document} match={props.match} {...rest} />
|
||||
</SocketPresence>
|
||||
);
|
||||
}
|
||||
|
||||
return <Document document={document} match={props.match} {...rest} />;
|
||||
}}
|
||||
</DataLoader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ class Search extends React.Component<Props> {
|
||||
const showShortcutTip =
|
||||
!this.pinToTop && location.state && location.state.fromMenu;
|
||||
const can = policies.abilities(auth.team?.id ? auth.team.id : "");
|
||||
const canCollection = policies.abilities(this.collectionId || "");
|
||||
|
||||
return (
|
||||
<Container auto>
|
||||
@@ -335,7 +336,9 @@ class Search extends React.Component<Props> {
|
||||
{can.createDocument && <Trans>Create a new document?</Trans>}
|
||||
</HelpText>
|
||||
<Wrapper>
|
||||
{this.collectionId && can.createDocument ? (
|
||||
{this.collectionId &&
|
||||
can.createDocument &&
|
||||
canCollection.update ? (
|
||||
<Button
|
||||
onClick={this.handleNewDoc}
|
||||
icon={<PlusIcon />}
|
||||
|
||||
@@ -18,38 +18,29 @@ function Security() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const [sharing, setSharing] = useState(team.documentEmbeds);
|
||||
const [documentEmbeds, setDocumentEmbeds] = useState(team.guestSignin);
|
||||
const [guestSignin, setGuestSignin] = useState(team.sharing);
|
||||
const [data, setData] = useState({
|
||||
sharing: team.sharing,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
guestSignin: team.guestSignin,
|
||||
});
|
||||
|
||||
const showSuccessMessage = debounce(() => {
|
||||
showToast(t("Settings saved"), { type: "success" });
|
||||
}, 500);
|
||||
const showSuccessMessage = React.useCallback(
|
||||
debounce(() => {
|
||||
showToast(t("Settings saved"), { type: "success" });
|
||||
}, 250),
|
||||
[t, showToast]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (ev: SyntheticInputEvent<*>) => {
|
||||
switch (ev.target.name) {
|
||||
case "sharing":
|
||||
setSharing(ev.target.checked);
|
||||
break;
|
||||
case "documentEmbeds":
|
||||
setDocumentEmbeds(ev.target.checked);
|
||||
break;
|
||||
case "guestSignin":
|
||||
setGuestSignin(ev.target.checked);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
const newData = { ...data, [ev.target.name]: ev.target.checked };
|
||||
setData(newData);
|
||||
|
||||
await auth.updateTeam({
|
||||
sharing,
|
||||
documentEmbeds,
|
||||
guestSignin,
|
||||
});
|
||||
await auth.updateTeam(newData);
|
||||
|
||||
showSuccessMessage();
|
||||
},
|
||||
[auth, sharing, documentEmbeds, guestSignin, showSuccessMessage]
|
||||
[auth, data, showSuccessMessage]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -67,14 +58,14 @@ function Security() {
|
||||
<Checkbox
|
||||
label={t("Allow email authentication")}
|
||||
name="guestSignin"
|
||||
checked={guestSignin}
|
||||
checked={data.guestSignin}
|
||||
onChange={handleChange}
|
||||
note={t("When enabled, users can sign-in using their email address")}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("Public document sharing")}
|
||||
name="sharing"
|
||||
checked={sharing}
|
||||
checked={data.sharing}
|
||||
onChange={handleChange}
|
||||
note={t(
|
||||
"When enabled, documents can be shared publicly on the internet by any team member"
|
||||
@@ -83,7 +74,7 @@ function Security() {
|
||||
<Checkbox
|
||||
label={t("Rich service embeds")}
|
||||
name="documentEmbeds"
|
||||
checked={documentEmbeds}
|
||||
checked={data.documentEmbeds}
|
||||
onChange={handleChange}
|
||||
note={t(
|
||||
"Links to supported services are shown as rich embeds within your documents"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// @flow
|
||||
import { sortBy, filter } from "lodash";
|
||||
import { computed } from "mobx";
|
||||
import Event from "models/Event";
|
||||
import BaseStore from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
|
||||
export default class EventsStore extends BaseStore<Event> {
|
||||
actions = ["list"];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Event);
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Event[] {
|
||||
return sortBy(Array.from(this.data.values()), "createdAt").reverse();
|
||||
}
|
||||
|
||||
inDocument(documentId: string): Event[] {
|
||||
return filter(this.orderedData, (event) => event.documentId === documentId);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
|
||||
import CollectionsStore from "./CollectionsStore";
|
||||
import DocumentPresenceStore from "./DocumentPresenceStore";
|
||||
import DocumentsStore from "./DocumentsStore";
|
||||
import EventsStore from "./EventsStore";
|
||||
import GroupMembershipsStore from "./GroupMembershipsStore";
|
||||
import GroupsStore from "./GroupsStore";
|
||||
import IntegrationsStore from "./IntegrationsStore";
|
||||
@@ -24,6 +25,7 @@ export default class RootStore {
|
||||
collections: CollectionsStore;
|
||||
collectionGroupMemberships: CollectionGroupMembershipsStore;
|
||||
documents: DocumentsStore;
|
||||
events: EventsStore;
|
||||
groups: GroupsStore;
|
||||
groupMemberships: GroupMembershipsStore;
|
||||
integrations: IntegrationsStore;
|
||||
@@ -46,6 +48,7 @@ export default class RootStore {
|
||||
this.collections = new CollectionsStore(this);
|
||||
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
|
||||
this.documents = new DocumentsStore(this);
|
||||
this.events = new EventsStore(this);
|
||||
this.groups = new GroupsStore(this);
|
||||
this.groupMemberships = new GroupMembershipsStore(this);
|
||||
this.integrations = new IntegrationsStore(this);
|
||||
@@ -66,6 +69,7 @@ export default class RootStore {
|
||||
this.collections.clear();
|
||||
this.collectionGroupMemberships.clear();
|
||||
this.documents.clear();
|
||||
this.events.clear();
|
||||
this.groups.clear();
|
||||
this.groupMemberships.clear();
|
||||
this.integrations.clear();
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import localStorage from '../../__mocks__/localStorage';
|
||||
import Enzyme from "enzyme";
|
||||
import Adapter from "enzyme-adapter-react-16";
|
||||
import { initI18n } from "shared/i18n";
|
||||
|
||||
initI18n();
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @flow
|
||||
import {
|
||||
isToday,
|
||||
isYesterday,
|
||||
differenceInCalendarWeeks,
|
||||
differenceInCalendarMonths,
|
||||
} from "date-fns";
|
||||
import * as React from "react";
|
||||
import { type TFunction } from "react-i18next";
|
||||
import LocaleTime from "components/LocaleTime";
|
||||
|
||||
export function dateToHeading(dateTime: string, t: TFunction) {
|
||||
const date = Date.parse(dateTime);
|
||||
const now = new Date();
|
||||
|
||||
if (isToday(date)) {
|
||||
return t("Today");
|
||||
}
|
||||
|
||||
if (isYesterday(date)) {
|
||||
return t("Yesterday");
|
||||
}
|
||||
|
||||
// If the current calendar week but not today or yesterday then return the day
|
||||
// of the week as a string. We use the LocaleTime component here to gain
|
||||
// async bundle loading of languages
|
||||
const weekDiff = differenceInCalendarWeeks(now, date);
|
||||
if (weekDiff === 0) {
|
||||
return <LocaleTime dateTime={dateTime} tooltip={false} format="iiii" />;
|
||||
}
|
||||
|
||||
if (weekDiff === 1) {
|
||||
return t("Last week");
|
||||
}
|
||||
|
||||
const monthDiff = differenceInCalendarMonths(now, date);
|
||||
if (monthDiff === 0) {
|
||||
return t("This month");
|
||||
}
|
||||
|
||||
if (monthDiff === 1) {
|
||||
return t("Last month");
|
||||
}
|
||||
|
||||
if (monthDiff <= 12) {
|
||||
return t("This year");
|
||||
}
|
||||
|
||||
// If older than the current calendar year then just print the year e.g 2020
|
||||
return <LocaleTime dateTime={dateTime} tooltip={false} format="y" />;
|
||||
}
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
declare var process: {
|
||||
exit: (code?: number) => void,
|
||||
cwd: () => string,
|
||||
argv: Array<string>,
|
||||
env: {
|
||||
[string]: string,
|
||||
},
|
||||
|
||||
+11
-3
@@ -7,10 +7,13 @@
|
||||
"clean": "rimraf build",
|
||||
"build:i18n": "i18next 'app/**/*.js' 'server/**/*.js' && mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n",
|
||||
"build:server": "babel -d ./build/server ./server && babel -d ./build/shared ./shared && cp package.json ./build && ln -sf \"$(pwd)/webpack.config.dev.js\" ./build",
|
||||
"build:multiplayer": "babel -d ./build/multiplayer ./multiplayer",
|
||||
"build:webpack": "webpack --config webpack.config.prod.js",
|
||||
"build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server",
|
||||
"start": "node ./build/server/index.js",
|
||||
"start:multiplayer": "node ./build/multiplayer/index.js",
|
||||
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/ --ignore flow-typed/",
|
||||
"dev:multiplayer": "nodemon --exec \"yarn build:server && node --inspect=0.0.0.0 build/server/index.js --multiplayer\" -e js --ignore build/ --ignore app/ --ignore flow-typed/",
|
||||
"lint": "eslint app server shared",
|
||||
"deploy": "git push heroku master",
|
||||
"prepare": "yarn yarn-deduplicate yarn.lock",
|
||||
@@ -45,6 +48,9 @@
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@babel/preset-flow": "^7.10.4",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@hocuspocus/extension-logger": "^1.0.0-alpha.35",
|
||||
"@hocuspocus/provider": "^1.0.0-alpha.8",
|
||||
"@hocuspocus/server": "^1.0.0-alpha.60",
|
||||
"@outlinewiki/koa-passport": "^4.1.4",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@sentry/node": "^6.3.1",
|
||||
@@ -143,7 +149,7 @@
|
||||
"react-window": "^1.8.6",
|
||||
"reakit": "^1.3.8",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rich-markdown-editor": "^11.17.0",
|
||||
"rich-markdown-editor": "^11.17.2",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
@@ -165,7 +171,9 @@
|
||||
"turndown": "^7.1.1",
|
||||
"utf8": "^3.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "5.2.0"
|
||||
"validator": "5.2.0",
|
||||
"y-prosemirror": "^1.0.9",
|
||||
"yjs": "^13.5.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.10.5",
|
||||
@@ -211,5 +219,5 @@
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.57.0"
|
||||
"version": "0.58.0"
|
||||
}
|
||||
+10
-2
@@ -310,9 +310,10 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collectionIds = await user.collectionIds();
|
||||
const userId = user.id;
|
||||
|
||||
const views = await View.findAll({
|
||||
where: { userId: user.id },
|
||||
where: { userId },
|
||||
order: [[sort, direction]],
|
||||
include: [
|
||||
{
|
||||
@@ -325,9 +326,16 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
|
||||
{
|
||||
model: Star,
|
||||
as: "starred",
|
||||
where: { userId: user.id },
|
||||
where: { userId },
|
||||
separate: true,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: Collection.scope({
|
||||
method: ["withMembership", userId],
|
||||
}),
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1349,6 +1349,7 @@ describe("#documents.viewed", () => {
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(document.id);
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
|
||||
it("should not return recently viewed but deleted documents", async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ router.post("team.update", auth(), async (ctx) => {
|
||||
sharing,
|
||||
guestSignin,
|
||||
documentEmbeds,
|
||||
multiplayerEditor,
|
||||
} = ctx.body;
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
@@ -31,6 +32,9 @@ router.post("team.update", auth(), async (ctx) => {
|
||||
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
|
||||
if (guestSignin !== undefined) team.guestSignin = guestSignin;
|
||||
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
|
||||
if (multiplayerEditor !== undefined) {
|
||||
team.multiplayerEditor = multiplayerEditor;
|
||||
}
|
||||
|
||||
const changes = team.changed();
|
||||
const data = {};
|
||||
|
||||
+2
-2
@@ -71,13 +71,13 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
await User.findAll({
|
||||
User.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
await User.count({
|
||||
User.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @flow
|
||||
import { uniq } from "lodash";
|
||||
import { schema, serializer } from "rich-markdown-editor";
|
||||
import { yDocToProsemirror } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import { Document, Event } from "../models";
|
||||
|
||||
export default async function documentUpdater({
|
||||
documentId,
|
||||
ydoc,
|
||||
userId,
|
||||
done,
|
||||
}: {
|
||||
documentId: string,
|
||||
ydoc: Y.Doc,
|
||||
userId: string,
|
||||
done?: boolean,
|
||||
}) {
|
||||
const document = await Document.findByPk(documentId);
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const node = yDocToProsemirror(schema, ydoc);
|
||||
const text = serializer.serialize(node);
|
||||
|
||||
// extract collaborators from doc user data
|
||||
const pud = new Y.PermanentUserData(ydoc);
|
||||
const pudIds = Array.from(pud.clients.values());
|
||||
const existingIds = document.collaboratorIds;
|
||||
const collaboratorIds = uniq([...pudIds, ...existingIds]);
|
||||
|
||||
if (document.text === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Document.update(
|
||||
{
|
||||
text,
|
||||
state: Buffer.from(state),
|
||||
updatedAt: new Date(),
|
||||
lastModifiedById: userId,
|
||||
collaboratorIds,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const event = {
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: userId,
|
||||
data: {
|
||||
multiplayer: true,
|
||||
title: document.title,
|
||||
},
|
||||
};
|
||||
|
||||
if (done) {
|
||||
await Event.create(event);
|
||||
} else {
|
||||
await Event.add(event);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,10 +1,11 @@
|
||||
// @flow
|
||||
|
||||
// Note: This entire object is stringified in the HTML exposed to the client
|
||||
// do not add anything here that should be a secret or password
|
||||
// do not add anything here that would be considered a secret or password
|
||||
export default {
|
||||
URL: process.env.URL,
|
||||
CDN_URL: process.env.CDN_URL || "",
|
||||
MULTIPLAYER_URL: process.env.MULTIPLAYER_URL || "",
|
||||
DEPLOYMENT: process.env.DEPLOYMENT,
|
||||
ENVIRONMENT: process.env.NODE_ENV,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
|
||||
+36
-32
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/node";
|
||||
import debug from "debug";
|
||||
import services from "./services";
|
||||
import { createQueue } from "./utils/queue";
|
||||
|
||||
const log = debug("services");
|
||||
@@ -195,39 +194,44 @@ export type Event =
|
||||
const globalEventsQueue = createQueue("global events");
|
||||
const serviceEventsQueue = createQueue("service events");
|
||||
|
||||
// this queue processes global events and hands them off to service hooks
|
||||
globalEventsQueue.process(async (job) => {
|
||||
const names = Object.keys(services);
|
||||
names.forEach((name) => {
|
||||
const service = services[name];
|
||||
if (service.on) {
|
||||
serviceEventsQueue.add(
|
||||
{ ...job.data, service: name },
|
||||
{ removeOnComplete: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
// TODO: This is a hack to prevent a require loop from models -> Event -> services -> main
|
||||
if (!process.argv.includes("--multiplayer")) {
|
||||
const services = require("./services");
|
||||
|
||||
// this queue processes an individual event for a specific service
|
||||
serviceEventsQueue.process(async (job) => {
|
||||
const event = job.data;
|
||||
const service = services[event.service];
|
||||
|
||||
if (service.on) {
|
||||
log(`${event.service} processing ${event.name}`);
|
||||
|
||||
service.on(event).catch((error) => {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.withScope(function (scope) {
|
||||
scope.setExtra("event", event);
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
// this queue processes global events and hands them off to service hooks
|
||||
globalEventsQueue.process(async (job) => {
|
||||
const names = Object.keys(services);
|
||||
names.forEach((name) => {
|
||||
const service = services[name];
|
||||
if (service.on) {
|
||||
serviceEventsQueue.add(
|
||||
{ ...job.data, service: name },
|
||||
{ removeOnComplete: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// this queue processes an individual event for a specific service
|
||||
serviceEventsQueue.process(async (job) => {
|
||||
const event = job.data;
|
||||
const service = services[event.service];
|
||||
|
||||
if (service.on) {
|
||||
log(`${event.service} processing ${event.name}`);
|
||||
|
||||
service.on(event).catch((error) => {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.withScope(function (scope) {
|
||||
scope.setExtra("event", event);
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default globalEventsQueue;
|
||||
|
||||
+17
-6
@@ -111,11 +111,22 @@ Is your team enjoying Outline? Consider supporting future development by sponsor
|
||||
);
|
||||
}
|
||||
|
||||
const { start } = require("./main");
|
||||
const isMultiplayer = process.argv.includes("--multiplayer");
|
||||
|
||||
throng({
|
||||
worker: start,
|
||||
if (isMultiplayer) {
|
||||
Error.stackTraceLimit = Infinity;
|
||||
|
||||
// The number of workers to run, defaults to the number of CPUs available
|
||||
count: process.env.WEB_CONCURRENCY || undefined,
|
||||
});
|
||||
const { start } = require("./multiplayer");
|
||||
|
||||
// TODO: Not using throng until multiplayer server has multi-process support
|
||||
start();
|
||||
} else {
|
||||
const { start } = require("./main");
|
||||
|
||||
throng({
|
||||
worker: start,
|
||||
|
||||
// The number of workers to run, defaults to the number of CPUs available
|
||||
count: process.env.WEB_CONCURRENCY || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('documents', 'state', {
|
||||
type: Sequelize.BLOB
|
||||
});
|
||||
await queryInterface.addColumn('teams', 'multiplayerEditor', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('documents', 'state');
|
||||
await queryInterface.removeColumn('teams', 'multiplayerEditor');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeIndex("documents", "documents_collaborator_ids");
|
||||
await queryInterface.removeIndex("documents", "documents_id_deleted_at");
|
||||
await queryInterface.removeIndex("users", "users_slack_id");
|
||||
await queryInterface.removeIndex("teams", "teams_slack_id");
|
||||
await queryInterface.removeIndex("teams", "teams_google_id");
|
||||
await queryInterface.removeIndex("collection_users", "collection_users_permission");
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addIndex("documents", ["collaboratorIds"]);
|
||||
await queryInterface.addIndex("documents", ["id", "deletedAt"]);
|
||||
await queryInterface.addIndex("users", ["slackId"]);
|
||||
await queryInterface.addIndex("teams", ["slackId"]);
|
||||
await queryInterface.addIndex("teams", ["googleId"]);
|
||||
await queryInterface.addIndex("collection_users", ["permission"]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('documents', 'backup');
|
||||
await queryInterface.removeColumn('revisions', 'backup');
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('documents', 'backup', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn('revisions', 'backup', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -81,6 +81,7 @@ const Document = sequelize.define(
|
||||
template: DataTypes.BOOLEAN,
|
||||
editorVersion: DataTypes.STRING,
|
||||
text: DataTypes.TEXT,
|
||||
state: DataTypes.BLOB,
|
||||
isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false },
|
||||
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||
archivedAt: DataTypes.DATE,
|
||||
@@ -192,13 +193,25 @@ Document.associate = (models) => {
|
||||
|
||||
return {
|
||||
include: [
|
||||
{ model: models.View, as: "views", where: { userId }, required: false },
|
||||
{
|
||||
model: models.View,
|
||||
as: "views",
|
||||
where: { userId },
|
||||
required: false,
|
||||
separate: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
Document.addScope("withStarred", (userId) => ({
|
||||
include: [
|
||||
{ model: models.Star, as: "starred", where: { userId }, required: false },
|
||||
{
|
||||
model: models.Star,
|
||||
as: "starred",
|
||||
where: { userId },
|
||||
required: false,
|
||||
separate: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -69,6 +69,11 @@ const Team = sequelize.define(
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
multiplayerEditor: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
@@ -170,9 +175,9 @@ Team.prototype.provisionFirstCollection = async function (userId) {
|
||||
// For the first collection we go ahead and create some intitial documents to get
|
||||
// the team started. You can edit these in /server/onboarding/x.md
|
||||
const onboardingDocs = [
|
||||
"Support",
|
||||
"Integrations & API",
|
||||
"Our Editor",
|
||||
"Getting Started",
|
||||
"What is Outline",
|
||||
];
|
||||
|
||||
@@ -182,7 +187,7 @@ Team.prototype.provisionFirstCollection = async function (userId) {
|
||||
"utf8"
|
||||
);
|
||||
const document = await Document.create({
|
||||
version: 1,
|
||||
version: 2,
|
||||
isWelcome: true,
|
||||
parentDocumentId: null,
|
||||
collectionId: collection.id,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { languages } from "../../shared/i18n";
|
||||
import { ValidationError } from "../errors";
|
||||
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
|
||||
import { DEFAULT_AVATAR_HOST } from "../utils/avatars";
|
||||
import { palette } from "../utils/color";
|
||||
import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3";
|
||||
import {
|
||||
UserAuthentication,
|
||||
@@ -74,6 +75,11 @@ const User = sequelize.define(
|
||||
.digest("hex");
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
|
||||
},
|
||||
color() {
|
||||
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
|
||||
const idAsNumber = parseInt(idAsHex, 16);
|
||||
return palette[idAsNumber % palette.length];
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @flow
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import debug from "debug";
|
||||
//import { RocksDB } from "@hocuspocus/extension-rocksdb";
|
||||
import { AuthenticationError } from "../errors";
|
||||
//import policy from "../policies";
|
||||
import { Document } from "../models";
|
||||
import { getUserForJWT } from "../utils/jwt";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const log = debug("multiplayer");
|
||||
//const { can } = policy;
|
||||
const can = () => true;
|
||||
|
||||
const server = Server.configure({
|
||||
port: process.env.MULTIPLAYER_PORT || process.env.PORT || 80,
|
||||
|
||||
async onConnect(data) {
|
||||
const { requestParameters, documentName } = data;
|
||||
|
||||
// allows for different entity types to use this multiplayer provider later
|
||||
const [, documentId] = documentName.split(".");
|
||||
const { token } = requestParameters;
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError("Authentication required");
|
||||
}
|
||||
|
||||
const user = await getUserForJWT(token);
|
||||
if (user.isSuspended) {
|
||||
throw new AuthenticationError("Account suspended");
|
||||
}
|
||||
|
||||
const document = await Document.findByPk(documentId, { userid: user.id });
|
||||
if (!can(user, "read", document)) {
|
||||
throw new AuthenticationError("Authorization required");
|
||||
}
|
||||
|
||||
// set document to read only for the current user, thus changes will not be
|
||||
// accepted and synced to other clients
|
||||
if (!can(user, "update", document)) {
|
||||
data.connection.readOnly = true;
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
};
|
||||
},
|
||||
|
||||
extensions: [
|
||||
new Logger(),
|
||||
// new RocksDB({
|
||||
// path: "./database",
|
||||
|
||||
// options: {
|
||||
// // see available options:
|
||||
// // https://www.npmjs.com/package/leveldown#options
|
||||
// createIfMissing: true,
|
||||
// },
|
||||
// }),
|
||||
],
|
||||
});
|
||||
|
||||
export async function start() {
|
||||
console.log(`Started multiplayer server`);
|
||||
server.listen();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
Some ideas to get you and your team started with learning the basics of Outline, feel free to check them off as you go!
|
||||
|
||||
## Learn the basics
|
||||
|
||||
- [x] Create an Outline account
|
||||
- [ ] **Create a collection** from the left sidebar
|
||||
- [ ] **Create a new doc** from the top right of home or any collection
|
||||
- [ ] Try drag and drop to nest and move documents
|
||||
- [ ] Share a document
|
||||
- [ ] Invite a co-worker 👋
|
||||
|
||||
## More to try
|
||||
|
||||
- [ ] Setup the [Slack integration](/settings/integrations/slack)
|
||||
- [ ] **Create a template** to share a writing structure with your team
|
||||
- [ ] Create a check list to track tasks
|
||||
- [ ] Try embedding a supported [integration](https://www.getoutline.com/integrations)
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
Outline supports many of the most popular tools on the market without any additional settings or configuration. Just paste links to a YouTube video, Figma file, or Google Spreadsheet to get instant live-embeds in your documents. Take a look at the [integrations directory](https://www.getoutline.com/integrations) for a list of all of the tools that are supported.
|
||||
|
||||
\
|
||||
Our integration code is also [open-source](https://github.com/outline/outline) and we encourage third party developers and the community to build support for additional tools!
|
||||
|
||||
\
|
||||
:::info
|
||||
Most integrations work by simply pasting a link from a supported service into a document.
|
||||
:::
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
The heart of Outline is the document editor. We let you write in whichever way you prefer – be it Markdown, WYSIWYG, or taking advantage of the many keyboard shortcuts.
|
||||
The heart of Outline is the document editor. We let you write in whichever way you prefer – be it Markdown, WYSIWYG, or taking advantage of the many keyboard shortcuts (Type `?` to see them all).
|
||||
|
||||
## Markdown
|
||||
|
||||
If you’re comfortable writing markdown then all of the shortcuts you are used to are supported, for example type \*\*bold\*\* to instantly create bold text. If you forget some syntax or are after a quick refresher hit the keyboard icon in the bottom right hand corner for our guide. Learning some of the key Markdown shortcuts will make using Outline faster and more enjoyable!
|
||||
|
||||

|
||||
If you’re comfortable writing markdown then all of the shortcuts you are used to are supported, for example type \*\*bold\*\* to instantly create **bold** text. If you forget some syntax or are after a quick refresher hit the keyboard icon in the bottom right hand corner for our guide. Learning some of the key Markdown shortcuts will definitely make using Outline faster and more enjoyable!
|
||||
|
||||
\
|
||||
:::info
|
||||
You can also paste markdown or html from elsewhere directly into a document.
|
||||
You can also paste markdown, html, or rich text from elsewhere directly into a document.
|
||||
:::
|
||||
|
||||
|
||||
## Rich documents
|
||||
|
||||
The editor supports a variety of content blocks including images, tables, lists, quotes, videos, and more. Type "/" on an empty line or click on the "+" icon to trigger the block insert menu, you can keep typing to filter it down.
|
||||
|
||||
You can also drag and drop images to include them in your document or paste a link to embed content from one of the many supported [integrations](https://www.getoutline.com/integrations)
|
||||
|
||||

|
||||
|
||||
## References
|
||||
|
||||
Linking to another document automatically creates backlinks which are kept up-to-date and shown at the bottom of the document, so you can create a library of linked information and easily answer the question "which other documents link here?".
|
||||
Linking to another document automatically creates backlinks which are kept up-to-date and shown at the bottom of the document, so you can create a library of linked information and easily answer the question "which other documents link here?".
|
||||
|
||||
## Rich documents
|
||||
|
||||
The editor supports a variety of content blocks including images, tables, lists, quotes, task lists, videos, and more. Type `/` on an empty line or click on the `+` icon to trigger the block insert menu, you can keep typing to filter it down. Of course, you can also drag and drop images to include them in your document or paste a link to embed content from one of the many supported [integrations](https://www.getoutline.com/integrations)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
We hate bugs as much as you and do everything possible to keep the application bug-free and performant. If you see any problems with Outline please get in touch with the team – you can email [hello@getoutline.com](mailto:hello@getoutline.com) directly and we’ll get back to you (hopefully with a fix!) as soon as possible.
|
||||
|
||||
## GitHub
|
||||
|
||||
If you have a GitHub account then you can also submit bugs directly to the development team on our public [issue tracker](https://github.com/outline/outline/issues).
|
||||
|
||||
## Ideas
|
||||
|
||||
We’d love to hear your ideas about how Outline can be improved and features you would like to see built. The best place to let the team know is through [GitHub discussions](https://github.com/outline/outline/discussions).
|
||||
@@ -1,16 +1,17 @@
|
||||
Outline is a place to build your team knowledge base, you could think of it like your team’s shared library – a place for important documentation, notes, and ideas to live and be discovered. Some things you might want to keep in Outline:
|
||||
Outline is a place to build your team knowledge base, you could think of it like your team’s shared library – a place for important documentation, notes, and ideas to live and be discovered. Some things you might want to keep in Outline include:
|
||||
|
||||
\
|
||||
- Documentation
|
||||
- Support knowledge base
|
||||
- Product plans and RFCs
|
||||
- Sales playbooks
|
||||
- Support scripts
|
||||
- Onboarding
|
||||
- HR documents
|
||||
- Onboarding checklists
|
||||
- Company policies
|
||||
- Meeting notes
|
||||
- …and more
|
||||
|
||||
## Structure
|
||||
|
||||
Outline allows you to organize documents in "collections", for example these could represent topics like Sales, Product, or HR. Within collections documents can be interlinked and deeply nested to easily build relationships within your knowledge base.
|
||||
Outline allows you to organize documents in "collections", for example these could represent topics like Sales, Product, or HR. You can assign users or groups access to collections. Within collections documents can be interlinked and deeply nested to easily build relationships within your knowledge base.
|
||||
|
||||
## Search
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import env from "../env";
|
||||
import { Team } from "../models";
|
||||
|
||||
export default function present(team: Team) {
|
||||
@@ -7,6 +8,7 @@ export default function present(team: Team) {
|
||||
name: team.name,
|
||||
avatarUrl: team.logoUrl,
|
||||
sharing: team.sharing,
|
||||
multiplayerEditor: team.multiplayerEditor && env.MULTIPLAYER_URL,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
guestSignin: team.guestSignin,
|
||||
subdomain: team.subdomain,
|
||||
|
||||
@@ -10,6 +10,7 @@ type UserPresentation = {
|
||||
name: string,
|
||||
avatarUrl: ?string,
|
||||
email?: string,
|
||||
color: string,
|
||||
isAdmin: boolean,
|
||||
isSuspended: boolean,
|
||||
isViewer: boolean,
|
||||
@@ -21,6 +22,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
|
||||
userData.id = user.id;
|
||||
userData.createdAt = user.createdAt;
|
||||
userData.name = user.name;
|
||||
userData.color = user.color;
|
||||
userData.isAdmin = user.isAdmin;
|
||||
userData.isViewer = user.isViewer;
|
||||
userData.isSuspended = user.isSuspended;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import type { DocumentEvent, RevisionEvent } from "../events";
|
||||
import { Document, Backlink } from "../models";
|
||||
import { Document, Team, Backlink } from "../models";
|
||||
import { Op } from "../sequelize";
|
||||
import parseDocumentIds from "../utils/parseDocumentIds";
|
||||
import slugify from "../utils/slugify";
|
||||
@@ -78,13 +78,19 @@ export default class Backlinks {
|
||||
break;
|
||||
}
|
||||
case "documents.title_change": {
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
if (!document) return;
|
||||
|
||||
// might as well check
|
||||
const { title, previousTitle } = event.data;
|
||||
if (!previousTitle || title === previousTitle) break;
|
||||
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
if (!document) return;
|
||||
|
||||
// TODO: Handle re-writing of titles into CRDT
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
if (team.multiplayerEditor) {
|
||||
break;
|
||||
}
|
||||
|
||||
// update any link titles in documents that lead to this one
|
||||
const backlinks = await Backlink.findAll({
|
||||
where: {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// @flow
|
||||
import { darken } from "polished";
|
||||
import theme from "../../shared/theme";
|
||||
|
||||
export const palette = [
|
||||
theme.brand.red,
|
||||
theme.brand.blue,
|
||||
theme.brand.purple,
|
||||
theme.brand.pink,
|
||||
theme.brand.marine,
|
||||
theme.brand.green,
|
||||
theme.brand.yellow,
|
||||
darken(0.2, theme.brand.red),
|
||||
darken(0.2, theme.brand.blue),
|
||||
darken(0.2, theme.brand.purple),
|
||||
darken(0.2, theme.brand.pink),
|
||||
darken(0.2, theme.brand.marine),
|
||||
darken(0.2, theme.brand.green),
|
||||
darken(0.2, theme.brand.yellow),
|
||||
];
|
||||
@@ -34,11 +34,11 @@
|
||||
"only you": "Nur Du",
|
||||
"person": "Person",
|
||||
"people": "Personen",
|
||||
"{{ total }} task": "{{ total }} task",
|
||||
"{{ total }} task_plural": "{{ total }} tasks",
|
||||
"{{ completed }} task done": "{{ completed }} task done",
|
||||
"{{ completed }} task done_plural": "{{ completed }} tasks done",
|
||||
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
||||
"{{ total }} task": "{{ total }} Aufgabe",
|
||||
"{{ total }} task_plural": "{{ total }} Aufgaben",
|
||||
"{{ completed }} task done": "{{ completed }} Aufgabe erledigt",
|
||||
"{{ completed }} task done_plural": "{{ completed }} Aufgaben erledigt",
|
||||
"{{ completed }} of {{ total }} tasks": "{{ completed }} von {{ total }} Aufgaben",
|
||||
"Currently editing": "Derzeit in Bearbeitung",
|
||||
"Currently viewing": "Gerade angezeigt",
|
||||
"Viewed {{ timeAgo }} ago": "Angesehen vor {{ timeAgo }}",
|
||||
@@ -50,7 +50,7 @@
|
||||
"Align left": "Links ausrichten",
|
||||
"Align right": "Rechts ausrichten",
|
||||
"Bulleted list": "Punkteliste",
|
||||
"Todo list": "Task list",
|
||||
"Todo list": "Aufgabenliste",
|
||||
"Code block": "Codeblock",
|
||||
"Copied to clipboard": "In die Zwischenablage kopiert",
|
||||
"Code": "Code",
|
||||
@@ -75,7 +75,7 @@
|
||||
"Divider": "Trennlinie",
|
||||
"Image": "Bild",
|
||||
"Sorry, an error occurred uploading the image": "Beim Hochladen des Bildes ist ein Fehler aufgetreten",
|
||||
"Write a caption": "Write a caption",
|
||||
"Write a caption": "Untertitel schreiben",
|
||||
"Info": "Info",
|
||||
"Info notice": "Info-Hinweis",
|
||||
"Link": "Link",
|
||||
@@ -101,20 +101,20 @@
|
||||
"Tip notice": "Tipp Hinweis",
|
||||
"Warning": "Warnung",
|
||||
"Warning notice": "Warnhinweis",
|
||||
"Module failed to load": "Module failed to load",
|
||||
"Loading Failed": "Loading Failed",
|
||||
"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.": "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.",
|
||||
"Reload": "Reload",
|
||||
"Something Unexpected Happened": "Something Unexpected Happened",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
|
||||
"our engineers have been notified": "our engineers have been notified",
|
||||
"Report a Bug": "Report a Bug",
|
||||
"Show Detail": "Show Detail",
|
||||
"Module failed to load": "Modul konnte nicht geladen werden",
|
||||
"Loading Failed": "Laden fehlgeschlagen",
|
||||
"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.": "Leider konnte ein Teil der Software nicht geladen werden weil der Inhalt sich inzwischen geändert hat oder eine Abfrage nicht erfolgreich abgeschlossen werden konnte. Bitte neu laden.",
|
||||
"Reload": "Neu Laden",
|
||||
"Something Unexpected Happened": "Ein unerwarteter Fehler ist aufgetreten",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Entschuldigung, ein nicht behebbarer Fehler ist aufgetreten{{notified}}. Bitte versuchen Sie die Seite neu zu laden, es könnte inzwischen wieder funktionieren.",
|
||||
"our engineers have been notified": "unsere Entwickler wurden benachrichtigt",
|
||||
"Report a Bug": "Fehler melden",
|
||||
"Show Detail": "Details anzeigen",
|
||||
"Icon": "Icon",
|
||||
"Show menu": "Menü anzeigen",
|
||||
"Choose icon": "Icon auswählen",
|
||||
"Loading": "Laden",
|
||||
"Loading editor": "Loading editor",
|
||||
"Loading editor": "Editor wird geladen",
|
||||
"Search": "Suche",
|
||||
"Default access": "Standardzugriff",
|
||||
"View and edit": "Anzeigen und bearbeiten",
|
||||
@@ -198,13 +198,13 @@
|
||||
"Create template": "Vorlage erstellen",
|
||||
"Duplicate": "Duplizieren",
|
||||
"Unpublish": "Nicht veröffentlichen",
|
||||
"Permanently delete": "Permanently delete",
|
||||
"Permanently delete": "Unwiderruflich löschen",
|
||||
"Move": "Verschieben",
|
||||
"History": "Verlauf",
|
||||
"Download": "Herunterladen",
|
||||
"Print": "Drucken",
|
||||
"Move {{ documentName }}": "Verschiebe {{ documentName }}",
|
||||
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
||||
"Permanently delete {{ documentName }}": "{{ documentName }} endgültig löschen",
|
||||
"Edit group": "Gruppe bearbeiten",
|
||||
"Delete group": "Gruppe löschen",
|
||||
"Group options": "Gruppen-Einstellungen",
|
||||
@@ -222,8 +222,8 @@
|
||||
"Share options": "Teilen-Einstellungen",
|
||||
"Go to document": "Zum Dokument gehen",
|
||||
"Revoke link": "Link widerrufen",
|
||||
"Contents": "Contents",
|
||||
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
|
||||
"Contents": "Inhalte",
|
||||
"Headings you add to the document will appear here": "Überschriften, die Sie dem Dokument hinzufügen, werden hier angezeigt",
|
||||
"Table of contents": "Inhaltsverzeichnis",
|
||||
"By {{ author }}": "Von {{ author }}",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Sind Sie sicher, dass Sie {{ userName }} zu einem Administrator machen möchten? Administratoren können Team- und Rechnungsinformationen ändern.",
|
||||
@@ -237,8 +237,8 @@
|
||||
"Revoke invite": "Einladung widerrufen",
|
||||
"Activate account": "Konto aktivieren",
|
||||
"Suspend account": "Konto sperren",
|
||||
"API token created": "API token created",
|
||||
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
|
||||
"API token created": "API-Token erstellt",
|
||||
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Nennen Sie Ihren Token so, dass Sie sich leicht daran erinnern können, z. B. \"development\" \"production\" oder \"continuous integration\".",
|
||||
"Documents": "Dokumente",
|
||||
"The document archive is empty at the moment.": "Das Dokumentenarchiv ist momentan leer.",
|
||||
"Search in collection": "Suche in Sammlung",
|
||||
@@ -254,7 +254,7 @@
|
||||
"Least recently updated": "Am längsten nicht aktualisiert",
|
||||
"A–Z": "A - Z",
|
||||
"Drop documents to import": "Dokumente zum Importieren hier ablegen",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Sind Sie sich sicher? Löschung der <em>{{collectionName}}</em> Sammlung ist dauerhaft und nicht wiederrufbar. Die Dokumente selbst werden jedoch dem Papierkorb hinzugefügt.",
|
||||
"Deleting": "Wird gelöscht",
|
||||
"I’m sure – Delete": "Ich bin mir sicher – Löschen",
|
||||
"The collection was updated": "Die Sammlung wurde aktualisiert",
|
||||
@@ -266,9 +266,9 @@
|
||||
"Public sharing is currently disabled in the team security settings.": "Öffentliches Teilen ist derzeit in den Sicherheitseinstellungen des Teams deaktiviert.",
|
||||
"Saving": "Speichert",
|
||||
"Save": "Speichern",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.",
|
||||
"Exporting": "Exporting",
|
||||
"Export Collection": "Export Collection",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Der Export der Sammlung <em>{{collectionName}}</em> kann einige Sekunden dauern. Ihre Dokumente werden als ZIP-Datei mit Markdown Textdateien heruntergeladen.",
|
||||
"Exporting": "Wird exportiert",
|
||||
"Export Collection": "Sammlung exportieren",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Sammlungen dienen zur Gruppierung von Dokumenten. Sie funktionieren am besten, wenn sie nach einem Thema oder nach internen Teams organisiert sind — z. B. Produkt oder Entwicklung.",
|
||||
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "Dies ist die Standardstufe des Zugriffs für Teammitglieder. Du kannst bestimmten Nutzern oder Gruppen mehr Zugriff geben, sobald die Sammlung erstellt wurde.",
|
||||
"Creating": "Wird erstellt",
|
||||
@@ -311,17 +311,17 @@
|
||||
"Add specific access for individual groups and team members": "Speziellen Zugriff für einzelne Gruppen und Teammitglieder hinzufügen",
|
||||
"Add groups to {{ collectionName }}": "Gruppen zu {{ collectionName }} hinzufügen",
|
||||
"Add people to {{ collectionName }}": "Personen zu {{ collectionName }} hinzufügen",
|
||||
"Document updated by {{userName}}": "Document updated by {{userName}}",
|
||||
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
"You’re editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "You’re editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.",
|
||||
"Archived by {{userName}}": "Archived by {{userName}}",
|
||||
"Deleted by {{userName}}": "Deleted by {{userName}}",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
|
||||
"Start your template…": "Start your template…",
|
||||
"Start with a title…": "Start with a title…",
|
||||
"…the rest is up to you": "…the rest is up to you",
|
||||
"Document updated by {{userName}}": "Dokument aktualisiert durch {{userName}}",
|
||||
"You have unsaved changes.\nAre you sure you want to discard them?": "Sie haben ungespeicherte Änderungen. Sind Sie sicher, dass Sie abbrechen wollen?",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Bilder werden noch hochgeladen.\nMöchten Sie sie wirklich verwerfen?",
|
||||
"You’re editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "Sie bearbeiten eine Vorlage. Markieren Sie eine Textstelle und verwenden Sie die <2></2> -control, um Platzhalter hinzuzufügen, die beim Erstellen neuer Dokumente aus dieser Vorlage ausgefüllt werden können.",
|
||||
"Archived by {{userName}}": "Archiviert durch {{userName}}",
|
||||
"Deleted by {{userName}}": "Gelöscht von {{userName}}",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "Diese Vorlage wird dauerhaft in <2></2> gelöscht, wenn sie nicht wiederhergestellt wird.",
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "Dieses Dokument wird dauerhaft in <2></2> gelöscht, wenn es nicht wiederhergestellt wird.",
|
||||
"Start your template…": "Vorlage starten…",
|
||||
"Start with a title…": "Mit dem Titel beginnen…",
|
||||
"…the rest is up to you": "…der Rest liegt bei Ihnen",
|
||||
"Hide contents": "Inhalt ausblenden",
|
||||
"Show contents": "Inhalt anzeigen",
|
||||
"Edit {{noun}}": "{{noun}} bearbeiten",
|
||||
@@ -347,15 +347,15 @@
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Bist du dir sicher? Durch Löschen des Dokuments <em>{{ documentTitle }}</em>, werden der gesamte Verlauf und alle Unterdokumente gelöscht.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "Wenn du {{noun}} in Zukunft noch referenzieren oder wiederherstellen möchtest, solltest du es stattdessen archivieren.",
|
||||
"Archiving": "Wird archiviert",
|
||||
"Document moved": "Document moved",
|
||||
"Current location": "Current location",
|
||||
"Choose a new location": "Choose a new location",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"Document moved": "Dokument verschoben",
|
||||
"Current location": "Aktueller Speicherort",
|
||||
"Choose a new location": "Wähle neuen Speicherort",
|
||||
"Search collections & documents": "Sammlungen und Dokumente durchsuchen",
|
||||
"Couldn’t create the document, try again?": "Dokument konnte nicht erstellt werden. Erneut versuchen?",
|
||||
"Document permanently deleted": "Document permanently deleted",
|
||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
|
||||
"Document permanently deleted": "Dokument endgültig gelöscht",
|
||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Möchten Sie das Dokument <em>{{ documentTitle }}</em> dauerhaft löschen? Die Löschung erfolgt sofort und kann nicht rückgängig gemacht werden.",
|
||||
"Template created, go ahead and customize it": "Vorlage erstellt, fortfahren und anpassen",
|
||||
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Das Erstellen einer Vorlage durch <em>{{titleWithDefault}}</em> ist eine non-destruktive Aktion – wir erstellen eine Kopie des Dokuments und verwandeln es in eine Vorlage, die als Ausgangspunkt für neue Dokumente verwendet werden kann.",
|
||||
"Search documents": "Dokumente durchsuchen",
|
||||
"No documents found for your filters.": "Keine Dokumente anhand Ihre Filter gefunden.",
|
||||
"You’ve not got any drafts at the moment.": "Sie haben im Moment keine Entwürfe.",
|
||||
@@ -365,8 +365,8 @@
|
||||
"We were unable to load the document while offline.": "Wir konnten das Dokument nicht offline laden.",
|
||||
"Your account has been suspended": "Ihr Konto wurde gesperrt",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Ein Administrator (<em>{{ suspendedContactEmail }}</em>) hat dein Konto gesperrt. Um dein Konto zu reaktivieren, wende dich bitte direkt an diesen.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Sind Sie sicher? Durch das Löschen der <em>{{groupName}}</em> verlieren Ihre Teammitglieder den Zugriff auf Sammlungen und Dokumente die mit der Gruppe verknüpft waren.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "Sie können den Namen dieser Gruppe jederzeit ändern. Dies kann jedoch Ihre Teammitglieder verwirren.",
|
||||
"{{userName}} was added to the group": "{{userName}} wurde zur Gruppe hinzugefügt",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "Fügen Sie unten Teammitglieder hinzu, um ihnen Zugriff auf die Gruppe zu gewähren. Sie müssen jemanden hinzufügen, der noch nicht im Team ist?",
|
||||
"Invite them to {{teamName}}": "Personen zu {{teamName}} einladen",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"Drafts": "Drafts",
|
||||
"Templates": "Templates",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"History": "History",
|
||||
"Oh weird, there's nothing here": "Oh weird, there's nothing here",
|
||||
"New": "New",
|
||||
"Only visible to you": "Only visible to you",
|
||||
"Draft": "Draft",
|
||||
@@ -110,6 +112,14 @@
|
||||
"our engineers have been notified": "our engineers have been notified",
|
||||
"Report a Bug": "Report a Bug",
|
||||
"Show Detail": "Show Detail",
|
||||
"Latest version": "Latest version",
|
||||
"{{userName}} edited": "{{userName}} edited",
|
||||
"{{userName}} archived": "{{userName}} archived",
|
||||
"{{userName}} restored": "{{userName}} restored",
|
||||
"{{userName}} deleted": "{{userName}} deleted",
|
||||
"{{userName}} moved from trash": "{{userName}} moved from trash",
|
||||
"{{userName}} published": "{{userName}} published",
|
||||
"{{userName}} moved": "{{userName}} moved",
|
||||
"Icon": "Icon",
|
||||
"Show menu": "Show menu",
|
||||
"Choose icon": "Choose icon",
|
||||
@@ -200,7 +210,6 @@
|
||||
"Unpublish": "Unpublish",
|
||||
"Permanently delete": "Permanently delete",
|
||||
"Move": "Move",
|
||||
"History": "History",
|
||||
"Download": "Download",
|
||||
"Print": "Print",
|
||||
"Move {{ documentName }}": "Move {{ documentName }}",
|
||||
@@ -552,5 +561,11 @@
|
||||
"Joined": "Joined",
|
||||
"{{ time }} ago.": "{{ time }} ago.",
|
||||
"Edit Profile": "Edit Profile",
|
||||
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet."
|
||||
"{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet.",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"Last week": "Last week",
|
||||
"This month": "This month",
|
||||
"Last month": "Last month",
|
||||
"This year": "This year"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"currently editing": "momenteel aan het bewerken",
|
||||
"currently viewing": "momenteen aan het bekijken",
|
||||
"previously edited": "previously edited",
|
||||
"previously edited": "eerder bewerkt",
|
||||
"You": "Jij",
|
||||
"Viewers": "Kijkers",
|
||||
"Sorry, an error occurred saving the collection": "Er is een fout opgetreden bij het opslaan van de collectie",
|
||||
"Add a description": "Voeg een beschrijving toe",
|
||||
"Collapse": "Collapse",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Inklappen",
|
||||
"Expand": "Uitklappen",
|
||||
"Submenu": "Submenu",
|
||||
"Trash": "Prullenmand",
|
||||
"Archive": "Archief",
|
||||
|
||||
@@ -34,11 +34,11 @@
|
||||
"only you": "仅您自己",
|
||||
"person": "人",
|
||||
"people": "用户",
|
||||
"{{ total }} task": "{{ total }} task",
|
||||
"{{ total }} task_plural": "{{ total }} tasks",
|
||||
"{{ completed }} task done": "{{ completed }} task done",
|
||||
"{{ completed }} task done_plural": "{{ completed }} tasks done",
|
||||
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
||||
"{{ total }} task": "一共 {{ total }} 项任务",
|
||||
"{{ total }} task_plural": "一共 {{ total }} 项任务",
|
||||
"{{ completed }} task done": "已完成 {{ completed }} 项任务",
|
||||
"{{ completed }} task done_plural": "已完成 {{ completed }} 项任务",
|
||||
"{{ completed }} of {{ total }} tasks": "一共 {{ total }} 项任务,已完成 {{ completed }} 项",
|
||||
"Currently editing": "正在编辑",
|
||||
"Currently viewing": "正在浏览",
|
||||
"Viewed {{ timeAgo }} ago": "{{ timeAgo }} 前查看",
|
||||
@@ -50,7 +50,7 @@
|
||||
"Align left": "左对齐",
|
||||
"Align right": "右对齐",
|
||||
"Bulleted list": "无序列表",
|
||||
"Todo list": "Task list",
|
||||
"Todo list": "任务列表",
|
||||
"Code block": "代码块",
|
||||
"Copied to clipboard": "已复制到剪切板",
|
||||
"Code": "代码",
|
||||
@@ -75,7 +75,7 @@
|
||||
"Divider": "分割线",
|
||||
"Image": "图片",
|
||||
"Sorry, an error occurred uploading the image": "抱歉,上传图片时发生错误",
|
||||
"Write a caption": "Write a caption",
|
||||
"Write a caption": "撰写一个标题",
|
||||
"Info": "信息",
|
||||
"Info notice": "提示信息",
|
||||
"Link": "链接",
|
||||
@@ -101,20 +101,20 @@
|
||||
"Tip notice": "提示信息",
|
||||
"Warning": "警告",
|
||||
"Warning notice": "警告信息",
|
||||
"Module failed to load": "Module failed to load",
|
||||
"Loading Failed": "Loading Failed",
|
||||
"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.": "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.",
|
||||
"Reload": "Reload",
|
||||
"Something Unexpected Happened": "Something Unexpected Happened",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
|
||||
"our engineers have been notified": "our engineers have been notified",
|
||||
"Report a Bug": "Report a Bug",
|
||||
"Show Detail": "Show Detail",
|
||||
"Module failed to load": "模块加载失败",
|
||||
"Loading Failed": "加载失败",
|
||||
"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.": "抱歉,应用程序有一部分加载失败。 这可能是因为在您打开标签后已经更新,或者因为网络请求失败。请尝试重新加载。",
|
||||
"Reload": "重新加载",
|
||||
"Something Unexpected Happened": "发生了一些意料之外的错误",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "抱歉,发生了一个无法恢复的错误{{notified}}。请尝试重新加载页面,这可能是一个暂时的小故障。",
|
||||
"our engineers have been notified": "我们的工程师已经收到通知",
|
||||
"Report a Bug": "反馈一个问题",
|
||||
"Show Detail": "显示详情",
|
||||
"Icon": "图标",
|
||||
"Show menu": "显示菜单",
|
||||
"Choose icon": "选择图标",
|
||||
"Loading": "加载中",
|
||||
"Loading editor": "Loading editor",
|
||||
"Loading editor": "正在加载编辑器",
|
||||
"Search": "搜索",
|
||||
"Default access": "默认访问",
|
||||
"View and edit": "查看并编辑",
|
||||
@@ -222,8 +222,8 @@
|
||||
"Share options": "分享选项",
|
||||
"Go to document": "转到文档",
|
||||
"Revoke link": "撤消链接",
|
||||
"Contents": "Contents",
|
||||
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
|
||||
"Contents": "目录",
|
||||
"Headings you add to the document will appear here": "您添加到文档的标题将显示在此处",
|
||||
"Table of contents": "目录",
|
||||
"By {{ author }}": "创建人 {{ author }}",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "您确定要设置 {{ userName }} 为管理员吗?管理员可以修改团队和帐单信息。",
|
||||
@@ -254,7 +254,7 @@
|
||||
"Least recently updated": "最近最少更新",
|
||||
"A–Z": "A-Z",
|
||||
"Drop documents to import": "拖放文档以导入",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "您确定要这样做吗? 删除 <em>{{collectionName}}</em> 文档集是永久性的,且无法还原。不过,文档集中的文件将被移动到回收站。",
|
||||
"Deleting": "正在删除",
|
||||
"I’m sure – Delete": "确认删除",
|
||||
"The collection was updated": "文档集已更新",
|
||||
@@ -266,9 +266,9 @@
|
||||
"Public sharing is currently disabled in the team security settings.": "公共共享目前在团队安全设置中被禁用。",
|
||||
"Saving": "保存中",
|
||||
"Save": "保存",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.",
|
||||
"Exporting": "Exporting",
|
||||
"Export Collection": "Export Collection",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "导出集合 <em>{{collectionName}}</em> 将会需要几秒钟。您的文档将会以 Markdown 格式放在文件夹中,并压缩成 zip 压缩包以供下载。",
|
||||
"Exporting": "正在导出……",
|
||||
"Export Collection": "导出文档集",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "文档集用于对文档进行分组。建议使用文档集管理同一主题或固定团队(比如产品团队或研发团队)的文档。",
|
||||
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "这是授予团队成员的默认访问级别,创建集合后,您可以为特定用户或用户组提供更多访问权限。",
|
||||
"Creating": "正在创建",
|
||||
@@ -311,17 +311,17 @@
|
||||
"Add specific access for individual groups and team members": "为个别组和团队成员添加特定访问权限",
|
||||
"Add groups to {{ collectionName }}": "将群组加到 {{ collectionName }}",
|
||||
"Add people to {{ collectionName }}": "添加用户到 {{ collectionName }}",
|
||||
"Document updated by {{userName}}": "Document updated by {{userName}}",
|
||||
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
"Document updated by {{userName}}": "文档已被 {{userName}} 更新",
|
||||
"You have unsaved changes.\nAre you sure you want to discard them?": "您有尚未保存的修改。\n您确定要丢弃它们吗?",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "图像仍在上传中。\n您确定要丢弃它们吗?",
|
||||
"You’re editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "You’re editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.",
|
||||
"Archived by {{userName}}": "Archived by {{userName}}",
|
||||
"Deleted by {{userName}}": "Deleted by {{userName}}",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
|
||||
"Start your template…": "Start your template…",
|
||||
"Archived by {{userName}}": "已被 {{userName}} 存档",
|
||||
"Deleted by {{userName}}": "已被 {{userName}} 删除",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "此模板将在 <2></2> 中永久删除,除非手动恢复。",
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "此文档将在 <2></2> 中永久删除,除非手动恢复。",
|
||||
"Start your template…": "开始创建模板…",
|
||||
"Start with a title…": "Start with a title…",
|
||||
"…the rest is up to you": "…the rest is up to you",
|
||||
"…the rest is up to you": "…其余部分由您决定。",
|
||||
"Hide contents": "隐藏内容",
|
||||
"Show contents": "显示内容",
|
||||
"Edit {{noun}}": "编辑 {{noun}}",
|
||||
@@ -347,14 +347,14 @@
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "删除文档 <em>{{ documentTitle }}</em> 也将会删除该文档所有历史版本和子文档,是否确认删除?",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "如果您将来希望引用或还原{{noun}},请考虑将其存档。",
|
||||
"Archiving": "正在归档",
|
||||
"Document moved": "Document moved",
|
||||
"Current location": "Current location",
|
||||
"Choose a new location": "Choose a new location",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"Document moved": "文档已被移动",
|
||||
"Current location": "当前位置",
|
||||
"Choose a new location": "选择新的位置",
|
||||
"Search collections & documents": "搜索文档集和文档",
|
||||
"Couldn’t create the document, try again?": "无法创建文档,请重试?",
|
||||
"Document permanently deleted": "文档已被永久删除",
|
||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "您确定要永久地删除 <em>{{ documentTitle }}</em> 文档吗?此操作即时生效且无法撤消。",
|
||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||
"Template created, go ahead and customize it": "模板已创建,继续进行自定义",
|
||||
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
|
||||
"Search documents": "搜索文档",
|
||||
"No documents found for your filters.": "没有找到相关文档。",
|
||||
@@ -374,27 +374,27 @@
|
||||
"Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.": "Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.",
|
||||
"Listing team members in the <em>{{groupName}}</em> group.": "Listing team members in the <em>{{groupName}}</em> group.",
|
||||
"This group has no members.": "这个群组没有任何成员",
|
||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||
"Add people to {{groupName}}": "添加用户到 {{groupName}}",
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"Continue": "Continue",
|
||||
"Group members": "Group members",
|
||||
"You’ll be able to add people to the group next.": "接下来,您将能够将人员添加到群组中。",
|
||||
"Continue": "继续操作",
|
||||
"Group members": "群组成员",
|
||||
"Recently viewed": "最近浏览",
|
||||
"Created by me": "由我创建",
|
||||
"Weird, this shouldn’t ever be empty": "Weird, this shouldn’t ever be empty",
|
||||
"Documents you’ve recently viewed will be here for easy access": "Documents you’ve recently viewed will be here for easy access",
|
||||
"We sent out your invites!": "We sent out your invites!",
|
||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
|
||||
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.",
|
||||
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.",
|
||||
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
|
||||
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
|
||||
"Weird, this shouldn’t ever be empty": "奇怪,这里不应该是空的",
|
||||
"Documents you’ve recently viewed will be here for easy access": "您最近浏览过的文档将放在此处以方便快速访问",
|
||||
"We sent out your invites!": "我们发送了您的邀请!",
|
||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "抱歉,您一次只能发送 {{MAX_INVITES}} 个邀请",
|
||||
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "邀请团队成员或访客加入您的知识库。团队成员可以使用 {{signinMethods}} 登录或使用他们的电子邮件地址登录。",
|
||||
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "邀请团队成员加入您的知识库。他们需要使用 {{signinMethods}} 登录。",
|
||||
"As an admin you can also <2>enable email sign-in</2>.": "作为管理员,您还可以<2>启用电子邮件登录</2>.",
|
||||
"Want a link to share directly with your team?": "想要一个链接直接分享到您的团队吗?",
|
||||
"Email": "邮箱",
|
||||
"Full name": "全名",
|
||||
"Remove invite": "Remove invite",
|
||||
"Add another": "Add another",
|
||||
"Inviting": "Inviting",
|
||||
"Send Invites": "Send Invites",
|
||||
"Remove invite": "撤消邀请",
|
||||
"Add another": "再添加一个",
|
||||
"Inviting": "邀请中……",
|
||||
"Send Invites": "发送邀请",
|
||||
"Navigation": "导航",
|
||||
"Edit current document": "编辑当前文档",
|
||||
"Move current document": "移动当前文档",
|
||||
@@ -424,19 +424,19 @@
|
||||
"Blockquote": "块引用",
|
||||
"Horizontal divider": "水平分隔线",
|
||||
"Inline code": "行内代码",
|
||||
"Back to home": "Back to home",
|
||||
"Back to website": "Back to website",
|
||||
"Check your email": "Check your email",
|
||||
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em>, no password needed.": "A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em>, no password needed.",
|
||||
"Back to login": "Back to login",
|
||||
"Create an account": "Create an account",
|
||||
"Get started by choosing a sign-in method for your new team below…": "Get started by choosing a sign-in method for your new team below…",
|
||||
"Login to {{ authProviderName }}": "Login to {{ authProviderName }}",
|
||||
"You signed in with {{ authProviderName }} last time.": "You signed in with {{ authProviderName }} last time.",
|
||||
"Already have an account? Go to <1>login</1>.": "Already have an account? Go to <1>login</1>.",
|
||||
"Sign In": "Sign In",
|
||||
"Continue with Email": "Continue with Email",
|
||||
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",
|
||||
"Back to home": "回到主页",
|
||||
"Back to website": "返回网站",
|
||||
"Check your email": "请查看您的电子邮件",
|
||||
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em>, no password needed.": "魔法登录链接已经发送到电子邮箱 <em>{{ emailLinkSentTo }}</em>,不需要密码。",
|
||||
"Back to login": "回到登录界面",
|
||||
"Create an account": "创建一个帐户",
|
||||
"Get started by choosing a sign-in method for your new team below…": "请在下面为您的新团队选择一种登陆方式",
|
||||
"Login to {{ authProviderName }}": "登录到 {{ authProviderName }}",
|
||||
"You signed in with {{ authProviderName }} last time.": "您上次登录的方式为 {{ authProviderName }}。",
|
||||
"Already have an account? Go to <1>login</1>.": "已经有帐户了?前往<1>登录</1>.",
|
||||
"Sign In": "登录",
|
||||
"Continue with Email": "使用电子邮件继续",
|
||||
"Continue with {{ authProviderName }}": "使用 {{ authProviderName }} 继续",
|
||||
"Any collection": "文档集",
|
||||
"Any time": "任何时间",
|
||||
"Past day": "过去一天",
|
||||
@@ -466,14 +466,14 @@
|
||||
"Active": "活动的",
|
||||
"Everyone": "所有人",
|
||||
"Admins": "管理員",
|
||||
"Settings saved": "Settings saved",
|
||||
"Unable to upload new logo": "Unable to upload new logo",
|
||||
"Settings saved": "设置已保存",
|
||||
"Unable to upload new logo": "无法上传新logo",
|
||||
"These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.",
|
||||
"Logo": "Logo",
|
||||
"Crop logo": "Crop logo",
|
||||
"Logo": "网站标志 (Logo)",
|
||||
"Crop logo": "裁剪标志",
|
||||
"Upload": "上传",
|
||||
"Subdomain": "Subdomain",
|
||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||
"Subdomain": "二级域名",
|
||||
"Your knowledge base will be accessible at": "您的知识库将在",
|
||||
"New group": "新建群组",
|
||||
"Groups can be used to organize and manage the people on your team.": "可以使用群组来组织和管理您团队中的人。",
|
||||
"All groups": "所有群组",
|
||||
@@ -491,20 +491,20 @@
|
||||
"Export Requested": "已请求导出",
|
||||
"Requesting Export": "正在请求导出",
|
||||
"Export Data": "导出数据",
|
||||
"Document published": "Document published",
|
||||
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
||||
"Document updated": "Document updated",
|
||||
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
|
||||
"Collection created": "Collection created",
|
||||
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
|
||||
"Getting started": "Getting started",
|
||||
"Document published": "文档已发布",
|
||||
"Receive a notification whenever a new document is published": "每当发布新文档时收到通知",
|
||||
"Document updated": "文档已更新",
|
||||
"Receive a notification when a document you created is edited": "每当您创建的文档被编辑时收到通知",
|
||||
"Collection created": "已创建文档集",
|
||||
"Receive a notification whenever a new collection is created": "每当创建新文档集时收到通知",
|
||||
"Getting started": "新手指南",
|
||||
"Tips on getting started with Outline`s features and functionality": "Tips on getting started with Outline`s features and functionality",
|
||||
"New features": "New features",
|
||||
"Receive an email when new features of note are added": "Receive an email when new features of note are added",
|
||||
"Notifications saved": "Notifications saved",
|
||||
"New features": "新特性",
|
||||
"Receive an email when new features of note are added": "当笔记增加新功能时收到电子邮件",
|
||||
"Notifications saved": "通知配置已保存",
|
||||
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
|
||||
"Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.": "Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.",
|
||||
"Email address": "Email address",
|
||||
"Email address": "电子邮件地址",
|
||||
"Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "每个登录到大纲的用户都会出现在这里。可能还有其他尚未登录的用户可以通过 {team.signinMethods} 访问。",
|
||||
"Filter": "筛选",
|
||||
"Profile saved": "配置已保存",
|
||||
@@ -517,8 +517,8 @@
|
||||
"You may delete your account at any time, note that this is unrecoverable": "您可以随时删除您的帐户,请注意删除后将无法恢复该账号",
|
||||
"Delete account": "删除账户",
|
||||
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
|
||||
"Allow email authentication": "Allow email authentication",
|
||||
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
|
||||
"Allow email authentication": "允许电子邮件身份验证",
|
||||
"When enabled, users can sign-in using their email address": "启用后,用户可以使用他们的电子邮件地址登录",
|
||||
"When enabled, documents can be shared publicly on the internet by any team member": "When enabled, documents can be shared publicly on the internet by any team member",
|
||||
"Rich service embeds": "Rich service embeds",
|
||||
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
|
||||
@@ -540,14 +540,14 @@
|
||||
"Create a token": "创建令牌",
|
||||
"Zapier": "Zapier",
|
||||
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
||||
"Open Zapier": "Open Zapier",
|
||||
"Open Zapier": "打开 Zapier",
|
||||
"You’ve not starred any documents yet.": "您尚未标记任何文档。",
|
||||
"There are no templates just yet.": "尚无可用模板。",
|
||||
"You can create templates to help your team create consistent and accurate documentation.": "您可以创建模板来帮助您的团队创建风格一致和准确的文档。",
|
||||
"Trash is empty at the moment.": "回收站为空。",
|
||||
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.",
|
||||
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
|
||||
"Delete My Account": "Delete My Account",
|
||||
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.": "你确定吗?注销您的帐户将销毁与您的用户相关联的识别数据并且无法撤消。您将立即退出 Outline,并且您的所有 API 令牌都将被撤销。",
|
||||
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>注意:</em> 重新登录将导致自动重新配置新帐户。",
|
||||
"Delete My Account": "注销我的帐户",
|
||||
"You joined": "您已加入",
|
||||
"Joined": "已加入",
|
||||
"{{ time }} ago.": "{{ time }} 之前。",
|
||||
|
||||
@@ -40,6 +40,7 @@ const colors = {
|
||||
blue: "#3633FF",
|
||||
marine: "#2BC2FF",
|
||||
green: "#42DED1",
|
||||
yellow: "#F5BE31",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1116,6 +1116,36 @@
|
||||
dependencies:
|
||||
"@hapi/hoek" "^8.3.0"
|
||||
|
||||
"@hocuspocus/extension-logger@^1.0.0-alpha.35":
|
||||
version "1.0.0-alpha.35"
|
||||
resolved "https://registry.yarnpkg.com/@hocuspocus/extension-logger/-/extension-logger-1.0.0-alpha.35.tgz#1d13c82b5da9eb91442e121f374f0a7609cd11fd"
|
||||
integrity sha512-yZ0pEmUvsfor9QhztAVnLOoY1LZpxA8UXJ0ZHr94oYgnmKo+aEK2P7xeiPT7GMH2t+ZYZn5UKoG3z4aaAEt6jg==
|
||||
dependencies:
|
||||
"@hocuspocus/server" "^1.0.0-alpha.60"
|
||||
|
||||
"@hocuspocus/provider@^1.0.0-alpha.8":
|
||||
version "1.0.0-alpha.8"
|
||||
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-1.0.0-alpha.8.tgz#6e6ccb7e29622df84680de542815695e651ffad9"
|
||||
integrity sha512-7BuHVISzv5RRrX4yU5JJ63/6epP6kkoJc97Th17Jm1rPybFA3/0LpmrLw8eEU35pRWtR0BWIzR9G7hPwjaOo1g==
|
||||
dependencies:
|
||||
lib0 "^0.2.42"
|
||||
y-protocols "^1.0.5"
|
||||
yjs "^13.5.8"
|
||||
|
||||
"@hocuspocus/server@^1.0.0-alpha.60":
|
||||
version "1.0.0-alpha.60"
|
||||
resolved "https://registry.yarnpkg.com/@hocuspocus/server/-/server-1.0.0-alpha.60.tgz#28221dfc8e5fa27d4b4e17e377ab766edde2a26e"
|
||||
integrity sha512-PKLIYHwk5NmssutlZr8DAIvqRWkDONpCMspDBKWnGbA+RiapVA7oIOZLTH37EpkkVOkRFpvhM+GAsOqUU2/K6w==
|
||||
dependencies:
|
||||
"@types/async-lock" "^1.1.2"
|
||||
"@types/uuid" "^8.3.0"
|
||||
"@types/ws" "^7.4.0"
|
||||
async-lock "^1.2.8"
|
||||
lib0 "^0.2.41"
|
||||
uuid "^8.3.2"
|
||||
ws "^7.4.3"
|
||||
yjs "^13.5.0"
|
||||
|
||||
"@icons/material@^0.2.4":
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
|
||||
@@ -2012,6 +2042,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@tommoor/remove-markdown/-/remove-markdown-0.3.2.tgz#5288ddd0e26b6b173e76ebb31c94653b0dcff45d"
|
||||
integrity sha512-awcc9hfLZqyyZHOGzAHbnjgZJpQGS1W1oZZ5GXOTTnbKVdKQ4OWYbrRWPUvXI2YAKJazrcS8rxPh67PX3rpGkQ==
|
||||
|
||||
"@types/async-lock@^1.1.2":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.3.tgz#0d86017cf87abbcb941c55360e533d37a3f23b3d"
|
||||
integrity sha512-UpeDcjGKsYEQMeqEbfESm8OWJI305I7b9KE4ji3aBjoKWyN5CTdn8izcA1FM1DVDne30R5fNEnIy89vZw5LXJQ==
|
||||
|
||||
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
|
||||
version "7.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d"
|
||||
@@ -2187,6 +2222,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
|
||||
integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
|
||||
|
||||
"@types/uuid@^8.3.0":
|
||||
version "8.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.1.tgz#1a32969cf8f0364b3d8c8af9cc3555b7805df14f"
|
||||
integrity sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==
|
||||
|
||||
"@types/ws@^7.4.0":
|
||||
version "7.4.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702"
|
||||
integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/yargs-parser@*":
|
||||
version "15.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d"
|
||||
@@ -2734,6 +2781,11 @@ async-each@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
|
||||
integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
|
||||
|
||||
async-lock@^1.2.8:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.3.0.tgz#0fba111bea8b9693020857eba4f9adca173df3e5"
|
||||
integrity sha512-8A7SkiisnEgME2zEedtDYPxUPzdv3x//E7n5IFktPAtMYSEAV7eNJF0rMwrVyUFj6d/8rgajLantbjcNRQYXIg==
|
||||
|
||||
asynckit@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
@@ -7499,6 +7551,11 @@ isomorphic-fetch@^3.0.0:
|
||||
node-fetch "^2.6.1"
|
||||
whatwg-fetch "^3.4.1"
|
||||
|
||||
isomorphic.js@^0.2.4:
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.4.tgz#24ca374163ae54a7ce3b86ce63b701b91aa84969"
|
||||
integrity sha512-Y4NjZceAwaPXctwsHgNsmfuPxR8lJ3f8X7QTAkhltrX4oGIv+eTlgHLXn4tWysC9zGTi929gapnPp+8F8cg7nA==
|
||||
|
||||
isstream@~0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
|
||||
@@ -8470,6 +8527,13 @@ levn@~0.3.0:
|
||||
prelude-ls "~1.1.2"
|
||||
type-check "~0.3.2"
|
||||
|
||||
lib0@^0.2.34, lib0@^0.2.41, lib0@^0.2.42:
|
||||
version "0.2.42"
|
||||
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.42.tgz#6d8bf1fb8205dec37a953c521c5ee403fd8769b0"
|
||||
integrity sha512-8BNM4MiokEKzMvSxTOC3gnCBisJH+jL67CnSnqzHv3jli3pUvGC8wz+0DQ2YvGr4wVQdb2R2uNNPw9LEpVvJ4Q==
|
||||
dependencies:
|
||||
isomorphic.js "^0.2.4"
|
||||
|
||||
lie@~3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
|
||||
@@ -11566,10 +11630,10 @@ retry-as-promised@^3.2.0:
|
||||
dependencies:
|
||||
any-promise "^1.3.0"
|
||||
|
||||
rich-markdown-editor@^11.17.0:
|
||||
version "11.17.0"
|
||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.17.0.tgz#9a39f5bd6518de1d0dc6c6ffa352fd3f7f664d96"
|
||||
integrity sha512-zCl9F7eeR3T5O2tiSU9iNGDOKhYBfTMqwWMPSY4ADjHcxELNkF1wOdKb0lqM8ZOfM63DkPEZy/N7cSifReyqvg==
|
||||
rich-markdown-editor@^11.17.2:
|
||||
version "11.17.2"
|
||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.17.2.tgz#dfe37db78f05275a127f3044e37c89e655003927"
|
||||
integrity sha512-YlforkrZlrvzDWz9C58O7+GU7ulJC8M+UdftMObhkt5LagpzBXXzPvOiMkR0qzlouxsEwSA22/S4J16obJpNWA==
|
||||
dependencies:
|
||||
copy-to-clipboard "^3.0.8"
|
||||
lodash "^4.17.11"
|
||||
@@ -14228,7 +14292,12 @@ write@1.0.3:
|
||||
dependencies:
|
||||
mkdirp "^0.5.1"
|
||||
|
||||
ws@^7.2.3, ws@~7.4.2:
|
||||
ws@^7.2.3, ws@^7.4.3:
|
||||
version "7.5.3"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.3.tgz#160835b63c7d97bfab418fc1b8a9fced2ac01a74"
|
||||
integrity sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==
|
||||
|
||||
ws@~7.4.2:
|
||||
version "7.4.6"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
|
||||
integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
|
||||
@@ -14309,6 +14378,20 @@ xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
|
||||
y-prosemirror@^1.0.9:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.0.9.tgz#c0b5bf4e2c6620093ba0658c2aca52055346a683"
|
||||
integrity sha512-OM12aPx04lwiIy1IOBidb6ONAof2KFxQE/Gww26SEsMQuA2dibrJkjaMwXwY1KnYY7yOpwbIFRdwecdNXLU9yQ==
|
||||
dependencies:
|
||||
lib0 "^0.2.34"
|
||||
|
||||
y-protocols@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.5.tgz#91d574250060b29fcac8f8eb5e276fbad594245e"
|
||||
integrity sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==
|
||||
dependencies:
|
||||
lib0 "^0.2.42"
|
||||
|
||||
y18n@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
|
||||
@@ -14405,6 +14488,13 @@ yeast@0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
|
||||
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
|
||||
|
||||
yjs@^13.5.0, yjs@^13.5.12, yjs@^13.5.8:
|
||||
version "13.5.12"
|
||||
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.5.12.tgz#7a0cf3119fb368c07243825e989a55de164b3f9c"
|
||||
integrity sha512-/buy1kh8Ls+t733Lgov9hiNxCsjHSCymTuZNahj2hsPNoGbvnSdDmCz9Z4F19Yr1eUAAXQLJF3q7fiBcvPC6Qg==
|
||||
dependencies:
|
||||
lib0 "^0.2.41"
|
||||
|
||||
ylru@^1.2.0:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
|
||||
|
||||
Reference in New Issue
Block a user