Merge branch 'main' into feat/mass-import

This commit is contained in:
Tom Moor
2021-02-17 23:57:45 -08:00
58 changed files with 2618 additions and 971 deletions

View File

@@ -6,7 +6,7 @@
<p align="center">
<i>An open, extensible, wiki for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
<br/>
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
<img src="https://www.getoutline.com/images/screenshot@2x.png" alt="Outline" width="800" />
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>

View File

@@ -30,6 +30,10 @@
"postdeploy": "yarn sequelize db:migrate"
},
"env": {
"NODE_ENV": {
"value": "production",
"required": true
},
"SECRET_KEY": {
"description": "A secret key",
"generator": "secret",
@@ -144,4 +148,4 @@
"required": false
}
}
}
}

23
app/components/Arrow.js Normal file
View File

@@ -0,0 +1,23 @@
// @flow
import * as React from "react";
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
);
}

View File

@@ -3,17 +3,18 @@ import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
type Props = {|
children?: React.Node,
};
withStickyHeader?: boolean,
|};
const Container = styled.div`
width: 100%;
max-width: 100vw;
padding: 60px 20px;
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: 60px;
padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};
`};
`;

View File

@@ -2,8 +2,9 @@
import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
@@ -51,7 +52,7 @@ class Collaborators extends React.Component<Props> {
const overflow = documentViews.length - mostRecentViewers.length;
return (
<Facepile
<FacepileHiddenOnMobile
users={mostRecentViewers.map((v) => v.user)}
overflow={overflow}
renderAvatar={(user) => {
@@ -75,4 +76,10 @@ class Collaborators extends React.Component<Props> {
}
}
const FacepileHiddenOnMobile = styled(Facepile)`
${breakpoint("mobile", "tablet")`
display: none;
`};
`;
export default inject("views", "presence")(Collaborators);

View File

@@ -0,0 +1,212 @@
// @flow
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Arrow from "components/Arrow";
import ButtonLink from "components/ButtonLink";
import Editor from "components/Editor";
import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
type Props = {|
collection: Collection,
|};
function CollectionDescription({ collection }: Props) {
const { collections, ui, policies } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = policies.abilities(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = useDebouncedCallback(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
ui.showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000);
const handleChange = React.useCallback(
(getValue) => {
setDirty(true);
handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input
$isEditable={can.update}
data-editing={isEditing}
data-expanded={isExpanded}
>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
id={collection.id}
key={key}
defaultValue={collection.description || ""}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
maxLength={1000}
disableEmbeds
readOnlyWriteCheckboxes
grow
/>
</React.Suspense>
) : (
can.update && <Placeholder>{placeholder}</Placeholder>
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
)}
</MaxHeight>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${(props) => props.theme.divider};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${(props) => props.theme.sidebarText};
}
`;
const Placeholder = styled(ButtonLink)`
color: ${(props) => props.theme.placeholder};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${(props) => props.theme.backgroundTransition};
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${(props) => props.theme.background} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${(props) => props.theme.secondaryBackground};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);

View File

@@ -163,8 +163,11 @@ const DocumentLink = styled(Link)`
padding: 6px 8px;
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
max-width: calc(100vw - 40px);
width: calc(100vw - 8px);
${breakpoint("tablet")`
width: auto;
`};
${Actions} {
opacity: 0;

View File

@@ -27,13 +27,16 @@ export type Props = {|
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
maxLength?: number,
scrollTo?: string,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
onDoubleClick?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
@@ -177,7 +180,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
justify-content: start;
> div {
transition: ${(props) => props.theme.backgroundTransition};
background: transparent;
}
& * {

104
app/components/Header.js Normal file
View File

@@ -0,0 +1,104 @@
// @flow
import { throttle } from "lodash";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
type Props = {|
breadcrumb?: React.Node,
title: React.Node,
actions?: React.Node,
|};
function Header({ breadcrumb, title, actions }: Props) {
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useCallback(
throttle(() => setScrolled(window.scrollY > 75), 50),
[]
);
React.useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
const handleClickTitle = React.useCallback(() => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}, []);
return (
<Wrapper
align="center"
justify="space-between"
isCompact={isScrolled}
shrink={false}
>
{breadcrumb}
{isScrolled ? (
<Title
align="center"
justify={breadcrumb ? "center" : "flex-start"}
onClick={handleClickTitle}
>
<Fade>
<Flex align="center">{title}</Flex>
</Fade>
</Title>
) : (
<div />
)}
{actions && <Actions>{actions}</Actions>}
</Wrapper>
);
}
const Wrapper = styled(Flex)`
position: sticky;
top: 0;
right: 0;
left: 0;
z-index: 2;
background: ${(props) => transparentize(0.2, props.theme.background)};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
@media print {
display: none;
}
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
width: 0;
${breakpoint("tablet")`
flex-grow: 1;
`};
`;
const Actions = styled(Flex)`
align-self: flex-end;
height: 32px;
`;
export default observer(Header);

View File

@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex";
const RealTextarea = styled.textarea`
@@ -33,6 +34,10 @@ const RealInput = styled.input`
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`;
const Wrapper = styled.div`

View File

@@ -7,7 +7,7 @@ import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { Switch, Route, Redirect } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
@@ -24,7 +24,6 @@ import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import {
homeUrl,
@@ -40,7 +39,6 @@ type Props = {
auth: AuthStore,
ui: UiStore,
notifications?: React.Node,
theme: Theme,
i18n: Object,
t: TFunction,
};
@@ -51,24 +49,12 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
constructor(props: Props) {
super();
this.updateBackground(props);
}
componentDidUpdate() {
this.updateBackground(this.props);
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
updateBackground(props: Props) {
// ensure the wider page color always matches the theme
window.document.body.style.background = props.theme.background;
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
@@ -212,5 +198,5 @@ const Content = styled(Flex)`
`;
export default withTranslation()<Layout>(
inject("auth", "ui", "documents")(withTheme(Layout))
inject("auth", "ui", "documents")(Layout)
);

View File

@@ -0,0 +1,35 @@
// @flow
import * as React from "react";
import { useTheme } from "styled-components";
import useStores from "hooks/useStores";
export default function PageTheme() {
const { ui } = useStores();
const theme = useTheme();
React.useEffect(() => {
// wider page background beyond the React root
if (document.body) {
document.body.style.background = theme.background;
}
// theme-color adjusts the title bar color for desktop PWA
const themeElement = document.querySelector('meta[name="theme-color"]');
if (themeElement) {
themeElement.setAttribute("content", theme.background);
}
// status bar color for iOS PWA
const statusElement = document.querySelector(
'meta[name="apple-mobile-web-app-status-bar-style"]'
);
if (statusElement) {
statusElement.setAttribute(
"content",
ui.resolvedTheme === "dark" ? "black-translucent" : "default"
);
}
}, [theme, ui.resolvedTheme]);
return null;
}

50
app/components/Scene.js Normal file
View File

@@ -0,0 +1,50 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import CenteredContent from "components/CenteredContent";
import Header from "components/Header";
import PageTitle from "components/PageTitle";
type Props = {|
icon?: React.Node,
title: React.Node,
textTitle?: string,
children: React.Node,
breadcrumb?: React.Node,
actions?: React.Node,
|};
function Scene({
title,
icon,
textTitle,
actions,
breadcrumb,
children,
}: Props) {
return (
<FillWidth>
<PageTitle title={textTitle || title} />
<Header
title={
icon ? (
<>
{icon}&nbsp;{title}
</>
) : (
title
)
}
actions={actions}
breadcrumb={breadcrumb}
/>
<CenteredContent withStickyHeader>{children}</CenteredContent>
</FillWidth>
);
}
const FillWidth = styled.div`
width: 100%;
`;
export default Scene;

View File

@@ -22,9 +22,9 @@ import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
@@ -72,7 +72,7 @@ function MainSidebar() {
<Sidebar>
<AccountMenu>
{(props) => (
<HeaderBlock
<TeamButton
{...props}
subheading={user.name}
teamName={team.name}

View File

@@ -21,9 +21,9 @@ import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import Version from "./components/Version";
import SlackIcon from "./icons/Slack";
import ZapierIcon from "./icons/Zapier";
@@ -46,7 +46,7 @@ function SettingsSidebar() {
return (
<Sidebar>
<HeaderBlock
<TeamButton
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}

View File

@@ -13,7 +13,7 @@ type Props = {|
logoUrl: string,
|};
const HeaderBlock = React.forwardRef<Props, any>(
const TeamButton = React.forwardRef<Props, any>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
<Wrapper>
<Header justify="flex-start" align="center" ref={ref} {...rest}>
@@ -25,8 +25,7 @@ const HeaderBlock = React.forwardRef<Props, any>(
/>
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
{teamName} {showDisclosure && <Disclosure color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
@@ -35,7 +34,7 @@ const HeaderBlock = React.forwardRef<Props, any>(
)
);
const StyledExpandedIcon = styled(ExpandedIcon)`
const Disclosure = styled(ExpandedIcon)`
position: absolute;
right: 0;
top: 0;
@@ -84,4 +83,4 @@ const Header = styled.button`
}
`;
export default HeaderBlock;
export default TeamButton;

View File

@@ -2,6 +2,7 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Arrow from "components/Arrow";
type Props = {
direction: "left" | "right",
@@ -14,22 +15,7 @@ const Toggle = React.forwardRef<Props, HTMLButtonElement>(
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
<Arrow />
</ToggleButton>
</Positioner>
);
@@ -60,6 +46,7 @@ export const ToggleButton = styled.button`
`;
export const Positioner = styled.div`
display: none;
z-index: 2;
position: absolute;
top: 0;
@@ -70,6 +57,10 @@ export const Positioner = styled.div`
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1;
}
${breakpoint("tablet")`
display: block;
`}
`;
export default Toggle;

View File

@@ -2,19 +2,17 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
type Props = {|
children: React.Node,
};
|};
const H3 = styled.h3`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin-top: 22px;
margin-bottom: 12px;
margin: 12px 0;
line-height: 1;
position: relative;
`;
const Underline = styled("span")`
const Underline = styled.div`
margin-top: -1px;
display: inline-block;
font-weight: 500;
@@ -22,14 +20,29 @@ const Underline = styled("span")`
line-height: 1.5;
color: ${(props) => props.theme.textSecondary};
border-bottom: 3px solid ${(props) => props.theme.textSecondary};
padding-bottom: 5px;
padding-top: 6px;
padding-bottom: 4px;
`;
// When sticky we need extra background coverage around the sides otherwise
// items that scroll past can "stick out" the sides of the heading
const Sticky = styled.div`
position: sticky;
top: 54px;
margin: 0 -8px;
padding: 0 8px;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
z-index: 1;
`;
const Subheading = ({ children, ...rest }: Props) => {
return (
<H3 {...rest}>
<Underline>{children}</Underline>
</H3>
<Sticky>
<H3 {...rest}>
<Underline>{children}</Underline>
</H3>
</Sticky>
);
};

View File

@@ -8,7 +8,7 @@ type Props = {
theme: Theme,
};
const StyledNavLink = styled(NavLink)`
const TabLink = styled(NavLink)`
position: relative;
display: inline-flex;
align-items: center;
@@ -16,7 +16,7 @@ const StyledNavLink = styled(NavLink)`
font-size: 14px;
color: ${(props) => props.theme.textTertiary};
margin-right: 24px;
padding-bottom: 8px;
padding: 6px 0;
&:hover {
color: ${(props) => props.theme.textSecondary};
@@ -32,7 +32,7 @@ function Tab({ theme, ...rest }: Props) {
color: theme.textSecondary,
};
return <StyledNavLink {...rest} activeStyle={activeStyle} />;
return <TabLink {...rest} activeStyle={activeStyle} />;
}
export default withTheme(Tab);

View File

@@ -1,16 +1,27 @@
// @flow
import * as React from "react";
import styled from "styled-components";
const Tabs = styled.nav`
position: relative;
const Nav = styled.nav`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin-top: 22px;
margin-bottom: 12px;
margin: 12px 0;
overflow-y: auto;
white-space: nowrap;
transition: opacity 250ms ease-out;
`;
// When sticky we need extra background coverage around the sides otherwise
// items that scroll past can "stick out" the sides of the heading
const Sticky = styled.div`
position: sticky;
top: 54px;
margin: 0 -8px;
padding: 0 8px;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
z-index: 1;
`;
export const Separator = styled.span`
border-left: 1px solid ${(props) => props.theme.divider};
position: relative;
@@ -19,4 +30,12 @@ export const Separator = styled.span`
margin-top: 6px;
`;
const Tabs = (props: {}) => {
return (
<Sticky>
<Nav {...props}></Nav>
</Sticky>
);
};
export default Tabs;

View File

@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Toast from "./components/Toast";
import Toast from "components/Toast";
import useStores from "hooks/useStores";
function Toasts() {

View File

@@ -1,3 +0,0 @@
// @flow
import Toasts from "./Toasts";
export default Toasts;

View File

@@ -0,0 +1,31 @@
// @flow
import * as React from "react";
export default function useDebouncedCallback(
callback: (any) => mixed,
wait: number
) {
// track args & timeout handle between calls
const argsRef = React.useRef();
const timeout = React.useRef();
function cleanup() {
if (timeout.current) {
clearTimeout(timeout.current);
}
}
// make sure our timeout gets cleared if consuming component gets unmounted
React.useEffect(() => cleanup, []);
return function (...args: any) {
argsRef.current = args;
cleanup();
timeout.current = setTimeout(() => {
if (argsRef.current) {
callback(...argsRef.current);
}
}, wait);
};
}

View File

@@ -10,6 +10,7 @@ import { Router } from "react-router-dom";
import { initI18n } from "shared/i18n";
import stores from "stores";
import ErrorBoundary from "components/ErrorBoundary";
import PageTheme from "components/PageTheme";
import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme";
import Toasts from "components/Toasts";
@@ -19,13 +20,28 @@ import { initSentry } from "utils/sentry";
initI18n();
const element = document.getElementById("root");
const element = window.document.getElementById("root");
const history = createBrowserHistory();
if (env.SENTRY_DSN) {
initSentry(history);
}
if ("serviceWorker" in window.navigator) {
window.addEventListener("load", () => {
window.navigator.serviceWorker
.register("/static/service-worker.js", {
scope: "/",
})
.then((registration) => {
console.log("SW registered: ", registration);
})
.catch((registrationError) => {
console.log("SW registration failed: ", registrationError);
});
});
}
if (element) {
render(
<Provider {...stores}>
@@ -34,6 +50,7 @@ if (element) {
<DndProvider backend={HTML5Backend}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>

View File

@@ -3,11 +3,11 @@ import * as React from "react";
import { Switch, Redirect, type Match } from "react-router-dom";
import Archive from "scenes/Archive";
import Collection from "scenes/Collection";
import Dashboard from "scenes/Dashboard";
import KeyedDocument from "scenes/Document/KeyedDocument";
import DocumentNew from "scenes/DocumentNew";
import Drafts from "scenes/Drafts";
import Error404 from "scenes/Error404";
import Home from "scenes/Home";
import Search from "scenes/Search";
import Starred from "scenes/Starred";
import Templates from "scenes/Templates";
@@ -37,8 +37,8 @@ export default function AuthenticatedRoutes() {
<Layout>
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Dashboard} />
<Route path="/home" component={Dashboard} />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />

View File

@@ -20,7 +20,7 @@ function Archive(props: Props) {
const { documents } = props;
return (
<CenteredContent column auto>
<CenteredContent>
<PageTitle title={t("Archive")} />
<Heading>{t("Archive")}</Heading>
<PaginatedDocumentList

View File

@@ -1,28 +1,26 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionMembers from "scenes/CollectionMembers";
import Search from "scenes/Search";
import Actions, { Action, Separator } from "components/Actions";
import { Action, Separator } from "components/Actions";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import CollectionDescription from "components/CollectionDescription";
import CollectionIcon from "components/CollectionIcon";
import DocumentList from "components/DocumentList";
import Editor from "components/Editor";
import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
@@ -30,14 +28,13 @@ import InputSearch from "components/InputSearch";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Mask from "components/Mask";
import Modal from "components/Modal";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import CollectionMenu from "menus/CollectionMenu";
import { type Theme } from "types";
import { AuthorizationError } from "utils/errors";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
@@ -47,7 +44,6 @@ type Props = {
collections: CollectionsStore,
policies: PoliciesStore,
match: Match,
theme: Theme,
t: TFunction,
};
@@ -57,7 +53,6 @@ class CollectionScene extends React.Component<Props> {
@observable isFetching: boolean = true;
@observable permissionsModalOpen: boolean = false;
@observable editModalOpen: boolean = false;
@observable redirectTo: ?string;
componentDidMount() {
const { id } = this.props.match.params;
@@ -108,14 +103,6 @@ class CollectionScene extends React.Component<Props> {
}
};
onNewDocument = (ev: SyntheticEvent<>) => {
ev.preventDefault();
if (this.collection) {
this.redirectTo = newDocumentUrl(this.collection.id);
}
};
onPermissions = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.permissionsModalOpen = true;
@@ -138,7 +125,7 @@ class CollectionScene extends React.Component<Props> {
const can = policies.abilities(match.params.id || "");
return (
<Actions align="center" justify="flex-end">
<>
{can.update && (
<>
<Action>
@@ -157,7 +144,12 @@ class CollectionScene extends React.Component<Props> {
delay={500}
placement="bottom"
>
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
<Button
as={Link}
to={this.collection ? newDocumentUrl(this.collection.id) : ""}
disabled={!this.collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
@@ -181,14 +173,13 @@ class CollectionScene extends React.Component<Props> {
)}
/>
</Action>
</Actions>
</>
);
}
render() {
const { documents, theme, t } = this.props;
const { documents, t } = this.props;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
if (!this.isFetching && !this.collection) return <Search notFound />;
const pinnedDocuments = this.collection
@@ -197,181 +188,171 @@ class CollectionScene extends React.Component<Props> {
const collection = this.collection;
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
const hasDescription = collection ? collection.hasDescription : false;
return (
<CenteredContent>
{collection ? (
return collection ? (
<Scene
textTitle={collection.name}
title={
<>
<PageTitle title={collection.name} />
{collection.isEmpty ? (
<Centered column>
<HelpText>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
<br />
<Trans>Get started by creating a new one!</Trans>
</HelpText>
<Wrapper>
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
{t("Create a document")}
</Button>
</Link>
&nbsp;&nbsp;
{collection.private && (
<Button onClick={this.onPermissions} neutral>
{t("Manage members")}
</Button>
)}
</Wrapper>
<Modal
title={t("Collection members")}
onRequestClose={this.handlePermissionsModalClose}
isOpen={this.permissionsModalOpen}
>
<CollectionMembers
collection={this.collection}
onSubmit={this.handlePermissionsModalClose}
onEdit={this.handleEditModalOpen}
/>
</Modal>
<Modal
title={t("Edit collection")}
onRequestClose={this.handleEditModalClose}
isOpen={this.editModalOpen}
>
<CollectionEdit
collection={this.collection}
onSubmit={this.handleEditModalClose}
/>
</Modal>
</Centered>
) : (
<>
<Heading>
<CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}
</Heading>
{hasDescription && (
<React.Suspense fallback={<p>Loading</p>}>
<Editor
id={collection.id}
key={collection.description}
defaultValue={collection.description}
readOnly
/>
</React.Suspense>
)}
{hasPinnedDocuments && (
<>
<Subheading>
<TinyPinIcon size={18} /> {t("Pinned")}
</Subheading>
<DocumentList documents={pinnedDocuments} showPin />
</>
)}
<Tabs>
<Tab to={collectionUrl(collection.id)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.id, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.id, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.id, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "recent")}>
<Redirect to={collectionUrl(collection.id, "published")} />
</Route>
<Route path={collectionUrl(collection.id, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{ collectionId: collection.id }}
showPublished
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showNestedDocuments
showPin
/>
</Route>
</Switch>
</>
)}
{this.renderActions()}
<CollectionIcon collection={collection} expanded />
&nbsp;
{collection.name}
</>
}
actions={this.renderActions()}
>
{collection.isEmpty ? (
<Centered column>
<HelpText>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{ collectionName }}
components={{ em: <strong /> }}
/>
<br />
<Trans>Get started by creating a new one!</Trans>
</HelpText>
<Empty>
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
&nbsp;&nbsp;
{collection.private && (
<Button onClick={this.onPermissions} neutral>
{t("Manage members")}
</Button>
)}
</Empty>
<Modal
title={t("Collection members")}
onRequestClose={this.handlePermissionsModalClose}
isOpen={this.permissionsModalOpen}
>
<CollectionMembers
collection={this.collection}
onSubmit={this.handlePermissionsModalClose}
onEdit={this.handleEditModalOpen}
/>
</Modal>
<Modal
title={t("Edit collection")}
onRequestClose={this.handleEditModalClose}
isOpen={this.editModalOpen}
>
<CollectionEdit
collection={this.collection}
onSubmit={this.handleEditModalClose}
/>
</Modal>
</Centered>
) : (
<>
<Heading>
<Mask height={35} />
<CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}
</Heading>
<ListPlaceholder count={5} />
<CollectionDescription collection={collection} />
{hasPinnedDocuments && (
<>
<Subheading>
<TinyPinIcon size={18} /> {t("Pinned")}
</Subheading>
<DocumentList documents={pinnedDocuments} showPin />
</>
)}
<Tabs>
<Tab to={collectionUrl(collection.id)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.id, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.id, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.id, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(collection.id)}
fetch={documents.fetchAlphabetical}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "recent")}>
<Redirect to={collectionUrl(collection.id, "published")} />
</Route>
<Route path={collectionUrl(collection.id, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{ collectionId: collection.id }}
showPublished
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{ collectionId: collection.id }}
showPin
/>
</Route>
<Route path={collectionUrl(collection.id)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showNestedDocuments
showPin
/>
</Route>
</Switch>
</>
)}
</Scene>
) : (
<CenteredContent>
<Heading>
<Mask height={35} />
</Heading>
<ListPlaceholder count={5} />
</CenteredContent>
);
}
@@ -390,16 +371,11 @@ const TinyPinIcon = styled(PinIcon)`
opacity: 0.8;
`;
const Wrapper = styled(Flex)`
const Empty = styled(Flex)`
justify-content: center;
margin: 10px 0;
`;
export default withTranslation()<CollectionScene>(
inject(
"collections",
"policies",
"documents",
"ui"
)(withTheme(CollectionScene))
inject("collections", "policies", "documents", "ui")(CollectionScene)
);

View File

@@ -11,7 +11,6 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputRich from "components/InputRich";
import InputSelect from "components/InputSelect";
import Switch from "components/Switch";
@@ -27,7 +26,6 @@ type Props = {
class CollectionEdit extends React.Component<Props> {
@observable name: string = this.props.collection.name;
@observable sharing: boolean = this.props.collection.sharing;
@observable description: string = this.props.collection.description;
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
@@ -43,7 +41,6 @@ class CollectionEdit extends React.Component<Props> {
try {
await this.props.collection.save({
name: this.name,
description: this.description,
icon: this.icon,
color: this.color,
private: this.private,
@@ -69,10 +66,6 @@ class CollectionEdit extends React.Component<Props> {
}
};
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
@@ -120,15 +113,6 @@ class CollectionEdit extends React.Component<Props> {
icon={this.icon}
/>
</Flex>
<InputRich
id={this.props.collection.id}
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<InputSelect
label={t("Sort in sidebar")}
options={[

View File

@@ -14,7 +14,6 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker, { icons } from "components/IconPicker";
import Input from "components/Input";
import InputRich from "components/InputRich";
import Switch from "components/Switch";
type Props = {
@@ -29,7 +28,6 @@ type Props = {
@observer
class CollectionNew extends React.Component<Props> {
@observable name: string = "";
@observable description: string = "";
@observable icon: string = "";
@observable color: string = "#4E5C6E";
@observable sharing: boolean = true;
@@ -43,7 +41,6 @@ class CollectionNew extends React.Component<Props> {
const collection = new Collection(
{
name: this.name,
description: this.description,
sharing: this.sharing,
icon: this.icon,
color: this.color,
@@ -90,10 +87,6 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.private = ev.target.checked;
};
@@ -115,9 +108,9 @@ class CollectionNew extends React.Component<Props> {
<form onSubmit={this.handleSubmit}>
<HelpText>
<Trans>
Collections are for grouping your knowledge base. They work best
when organized around a topic or internal team Product or
Engineering for example.
Collections are for grouping your documents. They work best when
organized around a topic or internal team Product or Engineering
for example.
</Trans>
</HelpText>
<Flex>
@@ -138,14 +131,6 @@ class CollectionNew extends React.Component<Props> {
icon={this.icon}
/>
</Flex>
<InputRich
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<Switch
id="private"
label={t("Private collection")}

View File

@@ -7,7 +7,6 @@ import { observer, inject } from "mobx-react";
import * as React from "react";
import type { RouterHistory, Match } from "react-router-dom";
import { withRouter } from "react-router-dom";
import { withTheme } from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
@@ -22,7 +21,7 @@ import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState, type Theme } from "types";
import { type LocationWithState } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
@@ -35,7 +34,6 @@ type Props = {|
policies: PoliciesStore,
revisions: RevisionsStore,
ui: UiStore,
theme: Theme,
history: RouterHistory,
|};
@@ -49,7 +47,6 @@ class DataLoader extends React.Component<Props> {
const { documents, match } = this.props;
this.document = documents.getByUrl(match.params.documentSlug);
this.loadDocument();
this.updateBackground();
}
componentDidUpdate(prevProps: Props) {
@@ -74,13 +71,6 @@ class DataLoader extends React.Component<Props> {
) {
this.loadRevision();
}
this.updateBackground();
}
updateBackground() {
// ensure the wider page color always matches the theme. This is to
// account for share links which don't sit in the wider Layout component
window.document.body.style.background = this.props.theme.background;
}
get isEditing() {
@@ -266,5 +256,5 @@ export default withRouter(
"revisions",
"policies",
"shares"
)(withTheme(DataLoader))
)(DataLoader)
);

View File

@@ -480,7 +480,7 @@ const ReferencesWrapper = styled("div")`
const MaxWidth = styled(Flex)`
${(props) =>
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
padding: 0 16px;
padding: 0 12px;
max-width: 100vw;
width: 100%;

View File

@@ -1,7 +1,5 @@
// @flow
import { throttle } from "lodash";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import {
TableOfContentsIcon,
EditIcon,
@@ -9,18 +7,11 @@ import {
PlusIcon,
MoreIcon,
} from "outline-icons";
import { transparentize, darken } from "polished";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import SharesStore from "stores/SharesStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import DocumentShare from "scenes/DocumentShare";
import { Action, Separator } from "components/Actions";
import Badge from "components/Badge";
@@ -28,20 +19,17 @@ import Breadcrumb, { Slash } from "components/Breadcrumb";
import Button from "components/Button";
import Collaborators from "components/Collaborators";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Header from "components/Header";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
type Props = {
auth: AuthStore,
ui: UiStore,
shares: SharesStore,
policies: PoliciesStore,
type Props = {|
document: Document,
isDraft: boolean,
isEditing: boolean,
@@ -56,356 +44,263 @@ type Props = {
publish?: boolean,
autosave?: boolean,
}) => void,
t: TFunction,
};
|};
@observer
class Header extends React.Component<Props> {
@observable isScrolled = false;
@observable showShareModal = false;
function DocumentHeader({
document,
isEditing,
isDraft,
isPublishing,
isRevision,
isSaving,
savingIsDisabled,
publishingIsDisabled,
onSave,
}: Props) {
const { t } = useTranslation();
const { auth, ui, shares, policies } = useStores();
const [showShareModal, setShowShareModal] = React.useState(false);
componentDidMount() {
window.addEventListener("scroll", this.handleScroll);
}
const handleSave = React.useCallback(() => {
onSave({ done: true });
}, [onSave]);
componentWillUnmount() {
window.removeEventListener("scroll", this.handleScroll);
}
const handlePublish = React.useCallback(() => {
onSave({ done: true, publish: true });
}, [onSave]);
updateIsScrolled = () => {
this.isScrolled = window.scrollY > 75;
};
const handleShareLink = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.share();
handleScroll = throttle(this.updateIsScrolled, 50);
setShowShareModal(true);
},
[document]
);
handleSave = () => {
this.props.onSave({ done: true });
};
const handleCloseShareModal = React.useCallback(() => {
setShowShareModal(false);
}, []);
handlePublish = () => {
this.props.onSave({ done: true, publish: true });
};
const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published;
const isNew = document.isNew;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canShareDocument = auth.team && auth.team.sharing && can.share;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
const canEdit = can.update && !isEditing;
handleShareLink = async (ev: SyntheticEvent<>) => {
const { document } = this.props;
await document.share();
this.showShareModal = true;
};
handleCloseShareModal = () => {
this.showShareModal = false;
};
handleClickTitle = () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
};
render() {
const {
shares,
document,
policies,
isEditing,
isDraft,
isPublishing,
isRevision,
isSaving,
savingIsDisabled,
publishingIsDisabled,
ui,
auth,
t,
} = this.props;
const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published;
const isNew = document.isNew;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canShareDocument = auth.team && auth.team.sharing && can.share;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
const canEdit = can.update && !isEditing;
return (
<Actions
align="center"
justify="space-between"
isCompact={this.isScrolled}
shrink={false}
return (
<>
<Modal
isOpen={showShareModal}
onRequestClose={handleCloseShareModal}
title={t("Share document")}
>
<Modal
isOpen={this.showShareModal}
onRequestClose={this.handleCloseShareModal}
title={t("Share document")}
>
<DocumentShare
document={document}
onSubmit={this.handleCloseShareModal}
/>
</Modal>
<Breadcrumb document={document}>
{!isEditing && (
<>
<Slash />
<Tooltip
tooltip={
ui.tocVisible ? t("Hide contents") : t("Show contents")
}
shortcut={`ctrl+${metaDisplay}+h`}
delay={250}
placement="bottom"
>
<Button
onClick={
ui.tocVisible
? ui.hideTableOfContents
: ui.showTableOfContents
<DocumentShare document={document} onSubmit={handleCloseShareModal} />
</Modal>
<Header
breadcrumb={
<Breadcrumb document={document}>
{!isEditing && (
<>
<Slash />
<Tooltip
tooltip={
ui.tocVisible ? t("Hide contents") : t("Show contents")
}
icon={<TableOfContentsIcon />}
iconColor="currentColor"
borderOnHover
neutral
/>
</Tooltip>
</>
)}
</Breadcrumb>
{this.isScrolled && (
<Title onClick={this.handleClickTitle}>
<Fade>
{document.title}{" "}
{document.isArchived && <Badge>{t("Archived")}</Badge>}
</Fade>
</Title>
)}
<Wrapper align="center" justify="flex-end">
{isSaving && !isPublishing && (
<Action>
<Status>{t("Saving")}</Status>
</Action>
)}
&nbsp;
<Fade>
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
</Fade>
{isEditing && !isTemplate && isNew && (
<Action>
<TemplatesMenu document={document} />
</Action>
)}
{!isEditing && canShareDocument && (
<Action>
<Tooltip
tooltip={
isPubliclyShared ? (
<Trans>
Anyone with the link <br />
can view this document
</Trans>
) : (
""
)
}
delay={500}
placement="bottom"
>
<Button
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
onClick={this.handleShareLink}
neutral
shortcut={`ctrl+${metaDisplay}+h`}
delay={250}
placement="bottom"
>
{t("Share")}
</Button>
</Tooltip>
</Action>
)}
{isEditing && (
<>
<Button
onClick={
ui.tocVisible
? ui.hideTableOfContents
: ui.showTableOfContents
}
icon={<TableOfContentsIcon />}
iconColor="currentColor"
borderOnHover
neutral
/>
</Tooltip>
</>
)}
</Breadcrumb>
}
title={
<>
{document.title}{" "}
{document.isArchived && <Badge>{t("Archived")}</Badge>}
</>
}
actions={
<>
{isSaving && !isPublishing && (
<Action>
<Status>{t("Saving")}</Status>
</Action>
)}
&nbsp;
<Fade>
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
</Fade>
{isEditing && !isTemplate && isNew && (
<Action>
<TemplatesMenu document={document} />
</Action>
)}
{!isEditing && canShareDocument && (
<Action>
<Tooltip
tooltip={t("Save")}
shortcut={`${metaDisplay}+enter`}
tooltip={
isPubliclyShared ? (
<Trans>
Anyone with the link <br />
can view this document
</Trans>
) : (
""
)
}
delay={500}
placement="bottom"
>
<Button
onClick={this.handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
onClick={handleShareLink}
neutral
>
{isDraft ? t("Save Draft") : t("Done Editing")}
{t("Share")}
</Button>
</Tooltip>
</Action>
</>
)}
{canEdit && (
<Action>
<Tooltip
tooltip={t("Edit {{noun}}", { noun: document.noun })}
shortcut="e"
delay={500}
placement="bottom"
>
<Button
as={Link}
icon={<EditIcon />}
to={editDocumentUrl(this.props.document)}
neutral
>
{t("Edit")}
</Button>
</Tooltip>
</Action>
)}
{canEdit && can.createChildDocument && (
<Action>
<NewChildDocumentMenu
document={document}
label={(props) => (
)}
{isEditing && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
tooltip={t("Save")}
shortcut={`${metaDisplay}+enter`}
delay={500}
placement="bottom"
>
<Button icon={<PlusIcon />} {...props} neutral>
{t("New doc")}
<Button
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
>
{isDraft ? t("Save Draft") : t("Done Editing")}
</Button>
</Tooltip>
)}
/>
</Action>
)}
{canEdit && isTemplate && !isDraft && !isRevision && (
<Action>
<Button
icon={<PlusIcon />}
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
primary
>
{t("New from template")}
</Button>
</Action>
)}
{can.update && isDraft && !isRevision && (
<Action>
<Tooltip
tooltip={t("Publish")}
shortcut={`${metaDisplay}+shift+p`}
delay={500}
placement="bottom"
>
<Button
onClick={this.handlePublish}
disabled={publishingIsDisabled}
>
{isPublishing ? `${t("Publishing")}` : t("Publish")}
</Button>
</Tooltip>
</Action>
)}
{!isEditing && (
<>
<Separator />
</Action>
</>
)}
{canEdit && (
<Action>
<DocumentMenu
<Tooltip
tooltip={t("Edit {{noun}}", { noun: document.noun })}
shortcut="e"
delay={500}
placement="bottom"
>
<Button
as={Link}
icon={<EditIcon />}
to={editDocumentUrl(document)}
neutral
>
{t("Edit")}
</Button>
</Tooltip>
</Action>
)}
{canEdit && can.createChildDocument && (
<Action>
<NewChildDocumentMenu
document={document}
isRevision={isRevision}
label={(props) => (
<Button
icon={<MoreIcon />}
iconColor="currentColor"
{...props}
borderOnHover
neutral
/>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button icon={<PlusIcon />} {...props} neutral>
{t("New doc")}
</Button>
</Tooltip>
)}
showToggleEmbeds={canToggleEmbeds}
showPrint
/>
</Action>
</>
)}
</Wrapper>
</Actions>
);
}
)}
{canEdit && isTemplate && !isDraft && !isRevision && (
<Action>
<Button
icon={<PlusIcon />}
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
primary
>
{t("New from template")}
</Button>
</Action>
)}
{can.update && isDraft && !isRevision && (
<Action>
<Tooltip
tooltip={t("Publish")}
shortcut={`${metaDisplay}+shift+p`}
delay={500}
placement="bottom"
>
<Button
onClick={handlePublish}
disabled={publishingIsDisabled}
>
{isPublishing ? `${t("Publishing")}` : t("Publish")}
</Button>
</Tooltip>
</Action>
)}
{!isEditing && (
<>
<Separator />
<Action>
<DocumentMenu
document={document}
isRevision={isRevision}
label={(props) => (
<Button
icon={<MoreIcon />}
iconColor="currentColor"
{...props}
borderOnHover
neutral
/>
)}
showToggleEmbeds={canToggleEmbeds}
showPrint
/>
</Action>
</>
)}
</>
}
/>
</>
);
}
const Status = styled.div`
color: ${(props) => props.theme.slate};
`;
const Wrapper = styled(Flex)`
width: 100%;
align-self: flex-end;
height: 32px;
${breakpoint("tablet")`
width: 33.3%;
`};
`;
const Actions = styled(Flex)`
position: sticky;
top: 0;
right: 0;
left: 0;
z-index: 2;
background: ${(props) => transparentize(0.2, props.theme.background)};
box-shadow: 0 1px 0
${(props) =>
props.isCompact
? darken(0.05, props.theme.sidebarBackground)
: "transparent"};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
@media print {
display: none;
}
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
> div {
width: 33.3%;
}
`};
`;
const Title = styled.div`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
display: none;
width: 0;
${breakpoint("tablet")`
display: flex;
flex-grow: 1;
`};
`;
export default withTranslation()<Header>(
inject("auth", "ui", "policies", "shares")(Header)
);
export default observer(DocumentHeader);

View File

@@ -1,6 +1,7 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
@@ -9,15 +10,13 @@ import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import CollectionFilter from "scenes/Search/components/CollectionFilter";
import DateFilter from "scenes/Search/components/DateFilter";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import { Action } from "components/Actions";
import Empty from "components/Empty";
import Flex from "components/Flex";
import Heading from "components/Heading";
import InputSearch from "components/InputSearch";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import NewDocumentMenu from "menus/NewDocumentMenu";
import { type LocationWithState } from "types";
@@ -78,8 +77,24 @@ class Drafts extends React.Component<Props> {
};
return (
<CenteredContent column auto>
<PageTitle title={t("Drafts")} />
<Scene
icon={<EditIcon color="currentColor" />}
title={t("Drafts")}
actions={
<>
<Action>
<InputSearch
source="drafts"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</>
}
>
<Heading>{t("Drafts")}</Heading>
<Subheading>
{t("Documents")}
@@ -110,20 +125,7 @@ class Drafts extends React.Component<Props> {
options={options}
showCollection
/>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch
source="drafts"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
</Scene>
);
}
}

View File

@@ -1,21 +1,21 @@
// @flow
import { observer } from "mobx-react";
import { HomeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import { Action } from "components/Actions";
import Heading from "components/Heading";
import InputSearch from "components/InputSearch";
import LanguagePrompt from "components/LanguagePrompt";
import PageTitle from "components/PageTitle";
import Scene from "components/Scene";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import PaginatedDocumentList from "../components/PaginatedDocumentList";
import useStores from "../hooks/useStores";
import NewDocumentMenu from "menus/NewDocumentMenu";
function Dashboard() {
function Home() {
const { documents, ui, auth } = useStores();
const { t } = useTranslation();
@@ -23,10 +23,26 @@ function Dashboard() {
const user = auth.user.id;
return (
<CenteredContent>
<PageTitle title={t("Home")} />
<Scene
icon={<HomeIcon color="currentColor" />}
title={t("Home")}
actions={
<>
<Action>
<InputSearch
source="dashboard"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</>
}
>
{!ui.languagePromptDismissed && <LanguagePrompt />}
<h1>{t("Home")}</h1>
<Heading>{t("Home")}</Heading>
<Tabs>
<Tab to="/home" exact>
{t("Recently updated")}
@@ -62,20 +78,8 @@ function Dashboard() {
/>
</Route>
</Switch>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch
source="dashboard"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
</Scene>
);
}
export default observer(Dashboard);
export default observer(Home);

View File

@@ -1,15 +1,15 @@
// @flow
import { observer } from "mobx-react";
import { StarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { type Match } from "react-router-dom";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import { Action } from "components/Actions";
import Empty from "components/Empty";
import Heading from "components/Heading";
import InputSearch from "components/InputSearch";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import useStores from "hooks/useStores";
@@ -26,8 +26,24 @@ function Starred(props: Props) {
const { sort } = props.match.params;
return (
<CenteredContent column auto>
<PageTitle title={t("Starred")} />
<Scene
icon={<StarredIcon color="currentColor" />}
title={t("Starred")}
actions={
<>
<Action>
<InputSearch
source="starred"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</>
}
>
<Heading>{t("Starred")}</Heading>
<PaginatedDocumentList
heading={
@@ -45,20 +61,7 @@ function Starred(props: Props) {
documents={sort === "alphabetical" ? starredAlphabetical : starred}
showCollection
/>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch
source="starred"
label={t("Search documents")}
labelHidden
/>
</Action>
<Action>
<NewDocumentMenu />
</Action>
</Actions>
</CenteredContent>
</Scene>
);
}

View File

@@ -1,15 +1,14 @@
// @flow
import { observer } from "mobx-react";
import { TemplateIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { type Match } from "react-router-dom";
import Actions, { Action } from "components/Actions";
import CenteredContent from "components/CenteredContent";
import { Action } from "components/Actions";
import Empty from "components/Empty";
import Heading from "components/Heading";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import useStores from "hooks/useStores";
@@ -26,8 +25,15 @@ function Templates(props: Props) {
const { sort } = props.match.params;
return (
<CenteredContent column auto>
<PageTitle title={t("Templates")} />
<Scene
icon={<TemplateIcon color="currentColor" />}
title={t("Templates")}
actions={
<Action>
<NewTemplateMenu />
</Action>
}
>
<Heading>{t("Templates")}</Heading>
<PaginatedDocumentList
heading={
@@ -52,13 +58,7 @@ function Templates(props: Props) {
showCollection
showDraft
/>
<Actions align="center" justify="flex-end">
<Action>
<NewTemplateMenu />
</Action>
</Actions>
</CenteredContent>
</Scene>
);
}

View File

@@ -1,13 +1,12 @@
// @flow
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import Heading from "components/Heading";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import useStores from "hooks/useStores";
@@ -16,8 +15,7 @@ function Trash() {
const { documents } = useStores();
return (
<CenteredContent column auto>
<PageTitle title={t("Trash")} />
<Scene icon={<TrashIcon color="currentColor" />} title={t("Trash")}>
<Heading>{t("Trash")}</Heading>
<PaginatedDocumentList
documents={documents.deleted}
@@ -27,7 +25,7 @@ function Trash() {
showCollection
showTemplate
/>
</CenteredContent>
</Scene>
);
}

View File

@@ -55,9 +55,9 @@ function UserProfile(props: Props) {
time: distanceInWordsToNow(new Date(user.createdAt)),
})}
{user.isAdmin && (
<StyledBadge admin={user.isAdmin}>{t("Admin")}</StyledBadge>
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
)}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
{user.isSuspended && <StyledBadge>{t("Suspended")}</StyledBadge>}
{isCurrentUser && (
<Edit>
<Button

View File

@@ -7,6 +7,8 @@ import env from "env";
export function initSentry(history: RouterHistory) {
Sentry.init({
dsn: env.SENTRY_DSN,
environment: env.ENVIRONMENT,
release: env.RELEASE,
integrations: [
new Integrations.BrowserTracing({
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
@@ -14,6 +16,7 @@ export function initSentry(history: RouterHistory) {
],
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
ignoreErrors: [
"ResizeObserver loop completed with undelivered notifications",
"ResizeObserver loop limit exceeded",
"AuthorizationError",
"BadRequestError",

View File

@@ -67,9 +67,9 @@
"@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@rehooks/window-scroll-position": "^1.0.1",
"@sentry/node": "^5.23.0",
"@sentry/react": "^6.0.1",
"@sentry/tracing": "^6.0.1",
"@sentry/node": "^6.1.0",
"@sentry/react": "^6.1.0",
"@sentry/tracing": "^6.1.0",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "0.3.1",
"autotrack": "^2.4.1",
@@ -84,6 +84,7 @@
"copy-to-clipboard": "^3.0.6",
"core-js": "2",
"date-fns": "1.29.0",
"dd-trace": "^0.30.6",
"debug": "^4.1.1",
"dotenv": "^4.0.0",
"emoji-regex": "^6.5.1",
@@ -153,7 +154,7 @@
"react-waypoint": "^9.0.2",
"react-window": "^1.8.6",
"reakit": "^1.3.4",
"rich-markdown-editor": "^11.2.0-0",
"rich-markdown-editor": "^11.3.0",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
@@ -182,6 +183,7 @@
"babel-eslint": "^10.1.0",
"babel-jest": "^26.2.2",
"babel-loader": "^8.1.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"eslint": "^7.6.0",
"eslint-config-react-app": "3.0.6",
"eslint-plugin-flowtype": "^5.2.0",
@@ -204,11 +206,13 @@
"url-loader": "^0.6.2",
"webpack": "4.44.1",
"webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^3.0.0"
"webpack-manifest-plugin": "^3.0.0",
"webpack-pwa-manifest": "^4.3.0",
"workbox-webpack-plugin": "^6.1.0"
},
"resolutions": {
"dot-prop": "^5.2.0",
"js-yaml": "^3.13.1"
},
"version": "0.52.0"
}
"version": "0.53.1"
}

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

BIN
public/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,20 +0,0 @@
{
"short_name": "Outline",
"name": "Outline",
"icons": [
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/home?source=pwa",
"background_color": "#FFFFFF",
"display": "standalone",
"theme_color": "#FFFFFF"
}

View File

@@ -17,6 +17,15 @@
]
],
"plugins": [
"transform-class-properties"
"transform-class-properties",
[
"transform-inline-environment-variables",
{
"include": [
"SOURCE_COMMIT",
"SOURCE_VERSION"
]
}
]
]
}

View File

@@ -114,10 +114,11 @@ if (isProduction) {
// catch errors in one place, automatically set status and response headers
onerror(app);
if (process.env.SENTRY_DSN) {
if (env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
dsn: env.SENTRY_DSN,
environment: env.ENVIRONMENT,
release: env.RELEASE,
maxBreadcrumbs: 0,
ignoreErrors: [
// emitted by Koa when bots attempt to snoop on paths such as wp-admin

View File

@@ -1,12 +1,17 @@
// @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
export default {
URL: process.env.URL,
CDN_URL: process.env.CDN_URL || "",
DEPLOYMENT: process.env.DEPLOYMENT,
ENVIRONMENT: process.env.NODE_ENV,
SENTRY_DSN: process.env.SENTRY_DSN,
TEAM_LOGO: process.env.TEAM_LOGO,
SLACK_KEY: process.env.SLACK_KEY,
SLACK_APP_ID: process.env.SLACK_APP_ID,
SUBDOMAINS_ENABLED: process.env.SUBDOMAINS_ENABLED === "true",
GOOGLE_ANALYTICS_ID: process.env.GOOGLE_ANALYTICS_ID,
RELEASE: process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
};

View File

@@ -1,6 +1,16 @@
// @flow
require("dotenv").config({ silent: true });
// If the DataDog agent is installed and the DD_API_KEY environment variable is
// in the environment then we can safely attempt to start the DD tracer
if (process.env.DD_API_KEY) {
require("dd-trace").init({
// SOURCE_COMMIT is used by Docker Hub
// SOURCE_VERSION is used by Heroku
version: process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION,
});
}
if (
!process.env.SECRET_KEY ||
process.env.SECRET_KEY === "generate_a_new_key"

View File

@@ -67,6 +67,8 @@ router.get("/_health", (ctx) => (ctx.body = "OK"));
if (process.env.NODE_ENV === "production") {
router.get("/static/*", async (ctx) => {
ctx.set({
"Service-Worker-Allowed": "/",
"Access-Control-Allow-Origin": "*",
"Cache-Control": `max-age=${356 * 24 * 60 * 60}`,
});

View File

@@ -2,8 +2,11 @@
<html lang="en">
<head>
<title>Outline</title>
<meta name="theme-color" content="#FFF" />
<meta name="slack-app-id" content="//inject-slack-app-id//" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="description" content="A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…">
//inject-prefetch//
<link
@@ -12,7 +15,12 @@
href="/favicon-32.png"
sizes="32x32"
/>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link
rel="apple-touch-icon"
type="image/png"
href="/apple-touch-icon.png"
sizes="192x192"
/>
<link
rel="search"
type="application/opensearchdescription+xml"
@@ -46,7 +54,9 @@
</script>
<script>
if (window.localStorage && window.localStorage.getItem("theme") === "dark") {
window.document.querySelector("#root").style.background = "#111319";
var color = "#111319";
document.querySelector("#root").style.background = color;
document.querySelector('meta[name="theme-color"]').setAttribute("content", color);
}
</script>
</body>

View File

@@ -17,6 +17,11 @@ const s3 = new AWS.S3({
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
region: AWS_REGION,
endpoint: process.env.AWS_S3_UPLOAD_BUCKET_URL.includes(
AWS_S3_UPLOAD_BUCKET_NAME
)
? undefined
: new AWS.Endpoint(process.env.AWS_S3_UPLOAD_BUCKET_URL),
signatureVersion: "v4",
});
@@ -110,7 +115,6 @@ export const uploadToS3FromBuffer = async (
Key: key,
ContentType: contentType,
ContentLength: buffer.length,
ServerSideEncryption: "AES256",
Body: buffer,
})
.promise();
@@ -135,7 +139,6 @@ export const uploadToS3FromUrl = async (
Key: key,
ContentType: res.headers["content-type"],
ContentLength: res.headers["content-length"],
ServerSideEncryption: "AES256",
Body: buffer,
})
.promise();

View File

@@ -8,6 +8,10 @@
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Collapse": "Collapse",
"Expand": "Expand",
"Submenu": "Submenu",
"New": "New",
"Only visible to you": "Only visible to you",
@@ -94,7 +98,7 @@
"Settings": "Settings",
"Invite people": "Invite people",
"Create a collection": "Create a collection",
"Return to App": "Return to App",
"Return to App": "Back to App",
"Account": "Account",
"Profile": "Profile",
"Notifications": "Notifications",
@@ -108,8 +112,6 @@
"Import / Export": "Import / Export",
"Integrations": "Integrations",
"Installation": "Installation",
"Expand": "Expand",
"Collapse": "Collapse",
"Unstar": "Unstar",
"Star": "Star",
"Appearance": "Appearance",
@@ -204,8 +206,6 @@
"The collection was updated": "The collection was updated",
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.",
"Name": "Name",
"Description": "Description",
"More details about this collection…": "More details about this collection…",
"Alphabetical": "Alphabetical",
"Private collection": "Private collection",
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
@@ -237,12 +237,9 @@
"Never signed in": "Never signed in",
"Invited": "Invited",
"Admin": "Admin",
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example.",
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
"Creating": "Creating",
"Create": "Create",
"Recently viewed": "Recently viewed",
"Created by me": "Created by me",
"Search documents": "Search documents",
"Hide contents": "Hide contents",
"Show contents": "Show contents",
"Archived": "Archived",
@@ -260,6 +257,7 @@
"Deleting": "Deleting",
"Im sure  Delete": "Im sure  Delete",
"Archiving": "Archiving",
"Search documents": "Search documents",
"No documents found for your filters.": "No documents found for your filters.",
"Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.",
"Not found": "Not found",
@@ -275,6 +273,8 @@
"Could not remove user": "Could not remove user",
"Add people": "Add people",
"This group has no members.": "This group has no members.",
"Recently viewed": "Recently viewed",
"Created by me": "Created by me",
"Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and theres Markdown too.": "Outline is designed to be fast and easy to use. All of your usual keyboard shortcuts work here, and theres Markdown too.",
"Navigation": "Navigation",
"New document in current collection": "New document in current collection",

View File

@@ -37,6 +37,21 @@ export default createGlobalStyle`
text-rendering: optimizeLegibility;
}
@media (min-width: ${(props) =>
props.theme.breakpoints.tablet}px) and (display-mode: standalone) {
body:after {
content: "";
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 1px;
background: ${(props) => props.theme.divider};
z-index: ${(props) => props.theme.depths.pwaSeparator};
}
}
a {
color: ${(props) => props.theme.link};
text-decoration: none;

View File

@@ -114,6 +114,7 @@ export const base = {
toasts: 5000,
loadingIndicatorBar: 6000,
popover: 9000,
pwaSeparator: 10000,
},
};

View File

@@ -4,6 +4,8 @@ const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent');
const pkg = require("rich-markdown-editor/package.json");
const WebpackPwaManifest = require("webpack-pwa-manifest");
const WorkboxPlugin = require("workbox-webpack-plugin");
require('dotenv').config({ silent: true });
@@ -59,6 +61,30 @@ module.exports = {
new HtmlWebpackPlugin({
template: 'server/static/index.html',
}),
new WebpackPwaManifest({
name: "Outline",
short_name: "Outline",
background_color: "#fff",
theme_color: "#fff",
start_url: process.env.URL,
display: "standalone",
icons: [
{
src: path.resolve("public/icon-512.png"),
// For Chrome, you must provide at least a 192x192 pixel icon, and a 512x512 pixel icon.
// If only those two icon sizes are provided, Chrome will automatically scale the icons
// to fit the device. If you'd prefer to scale your own icons, and adjust them for
// pixel-perfection, provide icons in increments of 48dp.
sizes: [512, 192],
purpose: "any maskable",
},
]
}),
new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024, // For large bundles
}),
new RelativeCiAgentWebpackPlugin(),
],
stats: {

1629
yarn.lock

File diff suppressed because it is too large Load Diff