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"> <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> <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/> <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>
<p align="center"> <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> <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" "postdeploy": "yarn sequelize db:migrate"
}, },
"env": { "env": {
"NODE_ENV": {
"value": "production",
"required": true
},
"SECRET_KEY": { "SECRET_KEY": {
"description": "A secret key", "description": "A secret key",
"generator": "secret", "generator": "secret",
@@ -144,4 +148,4 @@
"required": false "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 styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
type Props = { type Props = {|
children?: React.Node, children?: React.Node,
}; withStickyHeader?: boolean,
|};
const Container = styled.div` const Container = styled.div`
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
padding: 60px 20px; padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")` ${breakpoint("tablet")`
padding: 60px; padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};
`}; `};
`; `;

View File

@@ -2,8 +2,9 @@
import { sortBy, keyBy } from "lodash"; import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react"; import { observer, inject } from "mobx-react";
import * as React from "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 { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore"; import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore"; import ViewsStore from "stores/ViewsStore";
import Document from "models/Document"; import Document from "models/Document";
@@ -51,7 +52,7 @@ class Collaborators extends React.Component<Props> {
const overflow = documentViews.length - mostRecentViewers.length; const overflow = documentViews.length - mostRecentViewers.length;
return ( return (
<Facepile <FacepileHiddenOnMobile
users={mostRecentViewers.map((v) => v.user)} users={mostRecentViewers.map((v) => v.user)}
overflow={overflow} overflow={overflow}
renderAvatar={(user) => { 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); 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; padding: 6px 8px;
border-radius: 8px; border-radius: 8px;
max-height: 50vh; max-height: 50vh;
min-width: 100%; width: calc(100vw - 8px);
max-width: calc(100vw - 40px);
${breakpoint("tablet")`
width: auto;
`};
${Actions} { ${Actions} {
opacity: 0; opacity: 0;

View File

@@ -27,13 +27,16 @@ export type Props = {|
autoFocus?: boolean, autoFocus?: boolean,
template?: boolean, template?: boolean,
placeholder?: string, placeholder?: string,
maxLength?: number,
scrollTo?: string, scrollTo?: string,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean, readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any, onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any, onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any, onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any, onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any, onCancel?: () => any,
onDoubleClick?: () => any,
onChange?: (getValue: () => string) => any, onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any, onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any, onHoverLink?: (event: MouseEvent) => any,
@@ -177,7 +180,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
justify-content: start; justify-content: start;
> div { > 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 * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden"; import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "components/Flex"; import Flex from "components/Flex";
const RealTextarea = styled.textarea` const RealTextarea = styled.textarea`
@@ -33,6 +34,10 @@ const RealInput = styled.input`
&::placeholder { &::placeholder {
color: ${(props) => props.theme.placeholder}; color: ${(props) => props.theme.placeholder};
} }
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`; `;
const Wrapper = styled.div` const Wrapper = styled.div`

View File

@@ -7,7 +7,7 @@ import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next"; import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown"; import keydown from "react-keydown";
import { Switch, Route, Redirect } from "react-router-dom"; 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 breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore"; import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
@@ -24,7 +24,6 @@ import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings"; import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent"; import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink"; import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard"; import { meta } from "utils/keyboard";
import { import {
homeUrl, homeUrl,
@@ -40,7 +39,6 @@ type Props = {
auth: AuthStore, auth: AuthStore,
ui: UiStore, ui: UiStore,
notifications?: React.Node, notifications?: React.Node,
theme: Theme,
i18n: Object, i18n: Object,
t: TFunction, t: TFunction,
}; };
@@ -51,24 +49,12 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string; @observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false; @observable keyboardShortcutsOpen: boolean = false;
constructor(props: Props) {
super();
this.updateBackground(props);
}
componentDidUpdate() { componentDidUpdate() {
this.updateBackground(this.props);
if (this.redirectTo) { if (this.redirectTo) {
this.redirectTo = undefined; 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}+.`) @keydown(`${meta}+.`)
handleToggleSidebar() { handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar(); this.props.ui.toggleCollapsedSidebar();
@@ -212,5 +198,5 @@ const Content = styled(Flex)`
`; `;
export default withTranslation()<Layout>( 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 Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import Collections from "./components/Collections"; import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section"; import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink"; import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu"; import AccountMenu from "menus/AccountMenu";
@@ -72,7 +72,7 @@ function MainSidebar() {
<Sidebar> <Sidebar>
<AccountMenu> <AccountMenu>
{(props) => ( {(props) => (
<HeaderBlock <TeamButton
{...props} {...props}
subheading={user.name} subheading={user.name}
teamName={team.name} teamName={team.name}

View File

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

View File

@@ -13,7 +13,7 @@ type Props = {|
logoUrl: string, logoUrl: string,
|}; |};
const HeaderBlock = React.forwardRef<Props, any>( const TeamButton = React.forwardRef<Props, any>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => ( ({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
<Wrapper> <Wrapper>
<Header justify="flex-start" align="center" ref={ref} {...rest}> <Header justify="flex-start" align="center" ref={ref} {...rest}>
@@ -25,8 +25,7 @@ const HeaderBlock = React.forwardRef<Props, any>(
/> />
<Flex align="flex-start" column> <Flex align="flex-start" column>
<TeamName showDisclosure> <TeamName showDisclosure>
{teamName}{" "} {teamName} {showDisclosure && <Disclosure color="currentColor" />}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName> </TeamName>
<Subheading>{subheading}</Subheading> <Subheading>{subheading}</Subheading>
</Flex> </Flex>
@@ -35,7 +34,7 @@ const HeaderBlock = React.forwardRef<Props, any>(
) )
); );
const StyledExpandedIcon = styled(ExpandedIcon)` const Disclosure = styled(ExpandedIcon)`
position: absolute; position: absolute;
right: 0; right: 0;
top: 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 * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import Arrow from "components/Arrow";
type Props = { type Props = {
direction: "left" | "right", direction: "left" | "right",
@@ -14,22 +15,7 @@ const Toggle = React.forwardRef<Props, HTMLButtonElement>(
return ( return (
<Positioner style={style}> <Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}> <ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<svg <Arrow />
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>
</ToggleButton> </ToggleButton>
</Positioner> </Positioner>
); );
@@ -60,6 +46,7 @@ export const ToggleButton = styled.button`
`; `;
export const Positioner = styled.div` export const Positioner = styled.div`
display: none;
z-index: 2; z-index: 2;
position: absolute; position: absolute;
top: 0; top: 0;
@@ -70,6 +57,10 @@ export const Positioner = styled.div`
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} { &:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1; opacity: 1;
} }
${breakpoint("tablet")`
display: block;
`}
`; `;
export default Toggle; export default Toggle;

View File

@@ -2,19 +2,17 @@
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
type Props = { type Props = {|
children: React.Node, children: React.Node,
}; |};
const H3 = styled.h3` const H3 = styled.h3`
border-bottom: 1px solid ${(props) => props.theme.divider}; border-bottom: 1px solid ${(props) => props.theme.divider};
margin-top: 22px; margin: 12px 0;
margin-bottom: 12px;
line-height: 1; line-height: 1;
position: relative;
`; `;
const Underline = styled("span")` const Underline = styled.div`
margin-top: -1px; margin-top: -1px;
display: inline-block; display: inline-block;
font-weight: 500; font-weight: 500;
@@ -22,14 +20,29 @@ const Underline = styled("span")`
line-height: 1.5; line-height: 1.5;
color: ${(props) => props.theme.textSecondary}; color: ${(props) => props.theme.textSecondary};
border-bottom: 3px solid ${(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) => { const Subheading = ({ children, ...rest }: Props) => {
return ( return (
<H3 {...rest}> <Sticky>
<Underline>{children}</Underline> <H3 {...rest}>
</H3> <Underline>{children}</Underline>
</H3>
</Sticky>
); );
}; };

View File

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

View File

@@ -1,16 +1,27 @@
// @flow // @flow
import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
const Tabs = styled.nav` const Nav = styled.nav`
position: relative;
border-bottom: 1px solid ${(props) => props.theme.divider}; border-bottom: 1px solid ${(props) => props.theme.divider};
margin-top: 22px; margin: 12px 0;
margin-bottom: 12px;
overflow-y: auto; overflow-y: auto;
white-space: nowrap; white-space: nowrap;
transition: opacity 250ms ease-out; 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` export const Separator = styled.span`
border-left: 1px solid ${(props) => props.theme.divider}; border-left: 1px solid ${(props) => props.theme.divider};
position: relative; position: relative;
@@ -19,4 +30,12 @@ export const Separator = styled.span`
margin-top: 6px; margin-top: 6px;
`; `;
const Tabs = (props: {}) => {
return (
<Sticky>
<Nav {...props}></Nav>
</Sticky>
);
};
export default Tabs; export default Tabs;

View File

@@ -2,7 +2,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import Toast from "./components/Toast"; import Toast from "components/Toast";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
function Toasts() { 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 { initI18n } from "shared/i18n";
import stores from "stores"; import stores from "stores";
import ErrorBoundary from "components/ErrorBoundary"; import ErrorBoundary from "components/ErrorBoundary";
import PageTheme from "components/PageTheme";
import ScrollToTop from "components/ScrollToTop"; import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme"; import Theme from "components/Theme";
import Toasts from "components/Toasts"; import Toasts from "components/Toasts";
@@ -19,13 +20,28 @@ import { initSentry } from "utils/sentry";
initI18n(); initI18n();
const element = document.getElementById("root"); const element = window.document.getElementById("root");
const history = createBrowserHistory(); const history = createBrowserHistory();
if (env.SENTRY_DSN) { if (env.SENTRY_DSN) {
initSentry(history); 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) { if (element) {
render( render(
<Provider {...stores}> <Provider {...stores}>
@@ -34,6 +50,7 @@ if (element) {
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<Router history={history}> <Router history={history}>
<> <>
<PageTheme />
<ScrollToTop> <ScrollToTop>
<Routes /> <Routes />
</ScrollToTop> </ScrollToTop>

View File

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

View File

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

View File

@@ -1,28 +1,26 @@
// @flow // @flow
import { observable } from "mobx"; import { observable } from "mobx";
import { observer, inject } from "mobx-react"; import { observer, inject } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons"; import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next"; import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom"; 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 CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore"; import UiStore from "stores/UiStore";
import Collection from "models/Collection"; import Collection from "models/Collection";
import CollectionEdit from "scenes/CollectionEdit"; import CollectionEdit from "scenes/CollectionEdit";
import CollectionMembers from "scenes/CollectionMembers"; import CollectionMembers from "scenes/CollectionMembers";
import Search from "scenes/Search"; import Search from "scenes/Search";
import Actions, { Action, Separator } from "components/Actions"; import { Action, Separator } from "components/Actions";
import Button from "components/Button"; import Button from "components/Button";
import CenteredContent from "components/CenteredContent"; import CenteredContent from "components/CenteredContent";
import CollectionDescription from "components/CollectionDescription";
import CollectionIcon from "components/CollectionIcon"; import CollectionIcon from "components/CollectionIcon";
import DocumentList from "components/DocumentList"; import DocumentList from "components/DocumentList";
import Editor from "components/Editor";
import Flex from "components/Flex"; import Flex from "components/Flex";
import Heading from "components/Heading"; import Heading from "components/Heading";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
@@ -30,14 +28,13 @@ import InputSearch from "components/InputSearch";
import { ListPlaceholder } from "components/LoadingPlaceholder"; import { ListPlaceholder } from "components/LoadingPlaceholder";
import Mask from "components/Mask"; import Mask from "components/Mask";
import Modal from "components/Modal"; import Modal from "components/Modal";
import PageTitle from "components/PageTitle";
import PaginatedDocumentList from "components/PaginatedDocumentList"; import PaginatedDocumentList from "components/PaginatedDocumentList";
import Scene from "components/Scene";
import Subheading from "components/Subheading"; import Subheading from "components/Subheading";
import Tab from "components/Tab"; import Tab from "components/Tab";
import Tabs from "components/Tabs"; import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip"; import Tooltip from "components/Tooltip";
import CollectionMenu from "menus/CollectionMenu"; import CollectionMenu from "menus/CollectionMenu";
import { type Theme } from "types";
import { AuthorizationError } from "utils/errors"; import { AuthorizationError } from "utils/errors";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers"; import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
@@ -47,7 +44,6 @@ type Props = {
collections: CollectionsStore, collections: CollectionsStore,
policies: PoliciesStore, policies: PoliciesStore,
match: Match, match: Match,
theme: Theme,
t: TFunction, t: TFunction,
}; };
@@ -57,7 +53,6 @@ class CollectionScene extends React.Component<Props> {
@observable isFetching: boolean = true; @observable isFetching: boolean = true;
@observable permissionsModalOpen: boolean = false; @observable permissionsModalOpen: boolean = false;
@observable editModalOpen: boolean = false; @observable editModalOpen: boolean = false;
@observable redirectTo: ?string;
componentDidMount() { componentDidMount() {
const { id } = this.props.match.params; 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<>) => { onPermissions = (ev: SyntheticEvent<>) => {
ev.preventDefault(); ev.preventDefault();
this.permissionsModalOpen = true; this.permissionsModalOpen = true;
@@ -138,7 +125,7 @@ class CollectionScene extends React.Component<Props> {
const can = policies.abilities(match.params.id || ""); const can = policies.abilities(match.params.id || "");
return ( return (
<Actions align="center" justify="flex-end"> <>
{can.update && ( {can.update && (
<> <>
<Action> <Action>
@@ -157,7 +144,12 @@ class CollectionScene extends React.Component<Props> {
delay={500} delay={500}
placement="bottom" 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")} {t("New doc")}
</Button> </Button>
</Tooltip> </Tooltip>
@@ -181,14 +173,13 @@ class CollectionScene extends React.Component<Props> {
)} )}
/> />
</Action> </Action>
</Actions> </>
); );
} }
render() { 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 />; if (!this.isFetching && !this.collection) return <Search notFound />;
const pinnedDocuments = this.collection const pinnedDocuments = this.collection
@@ -197,181 +188,171 @@ class CollectionScene extends React.Component<Props> {
const collection = this.collection; const collection = this.collection;
const collectionName = collection ? collection.name : ""; const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length; const hasPinnedDocuments = !!pinnedDocuments.length;
const hasDescription = collection ? collection.hasDescription : false;
return ( return collection ? (
<CenteredContent> <Scene
{collection ? ( textTitle={collection.name}
title={
<> <>
<PageTitle title={collection.name} /> <CollectionIcon collection={collection} expanded />
{collection.isEmpty ? ( &nbsp;
<Centered column> {collection.name}
<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()}
</> </>
}
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> <Heading>
<Mask height={35} /> <CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}
</Heading> </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> </CenteredContent>
); );
} }
@@ -390,16 +371,11 @@ const TinyPinIcon = styled(PinIcon)`
opacity: 0.8; opacity: 0.8;
`; `;
const Wrapper = styled(Flex)` const Empty = styled(Flex)`
justify-content: center; justify-content: center;
margin: 10px 0; margin: 10px 0;
`; `;
export default withTranslation()<CollectionScene>( export default withTranslation()<CollectionScene>(
inject( inject("collections", "policies", "documents", "ui")(CollectionScene)
"collections",
"policies",
"documents",
"ui"
)(withTheme(CollectionScene))
); );

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import { observer, inject } from "mobx-react";
import * as React from "react"; import * as React from "react";
import type { RouterHistory, Match } from "react-router-dom"; import type { RouterHistory, Match } from "react-router-dom";
import { withRouter } from "react-router-dom"; import { withRouter } from "react-router-dom";
import { withTheme } from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug"; import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
@@ -22,7 +21,7 @@ import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar"; import HideSidebar from "./HideSidebar";
import Loading from "./Loading"; import Loading from "./Loading";
import SocketPresence from "./SocketPresence"; import SocketPresence from "./SocketPresence";
import { type LocationWithState, type Theme } from "types"; import { type LocationWithState } from "types";
import { NotFoundError, OfflineError } from "utils/errors"; import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers"; import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls"; import { isInternalUrl } from "utils/urls";
@@ -35,7 +34,6 @@ type Props = {|
policies: PoliciesStore, policies: PoliciesStore,
revisions: RevisionsStore, revisions: RevisionsStore,
ui: UiStore, ui: UiStore,
theme: Theme,
history: RouterHistory, history: RouterHistory,
|}; |};
@@ -49,7 +47,6 @@ class DataLoader extends React.Component<Props> {
const { documents, match } = this.props; const { documents, match } = this.props;
this.document = documents.getByUrl(match.params.documentSlug); this.document = documents.getByUrl(match.params.documentSlug);
this.loadDocument(); this.loadDocument();
this.updateBackground();
} }
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
@@ -74,13 +71,6 @@ class DataLoader extends React.Component<Props> {
) { ) {
this.loadRevision(); 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() { get isEditing() {
@@ -266,5 +256,5 @@ export default withRouter(
"revisions", "revisions",
"policies", "policies",
"shares" "shares"
)(withTheme(DataLoader)) )(DataLoader)
); );

View File

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

View File

@@ -1,7 +1,5 @@
// @flow // @flow
import { throttle } from "lodash"; import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { import {
TableOfContentsIcon, TableOfContentsIcon,
EditIcon, EditIcon,
@@ -9,18 +7,11 @@ import {
PlusIcon, PlusIcon,
MoreIcon, MoreIcon,
} from "outline-icons"; } from "outline-icons";
import { transparentize, darken } from "polished";
import * as React from "react"; 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 { Link } from "react-router-dom";
import styled from "styled-components"; 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 Document from "models/Document";
import DocumentShare from "scenes/DocumentShare"; import DocumentShare from "scenes/DocumentShare";
import { Action, Separator } from "components/Actions"; import { Action, Separator } from "components/Actions";
import Badge from "components/Badge"; import Badge from "components/Badge";
@@ -28,20 +19,17 @@ import Breadcrumb, { Slash } from "components/Breadcrumb";
import Button from "components/Button"; import Button from "components/Button";
import Collaborators from "components/Collaborators"; import Collaborators from "components/Collaborators";
import Fade from "components/Fade"; import Fade from "components/Fade";
import Flex from "components/Flex"; import Header from "components/Header";
import Modal from "components/Modal"; import Modal from "components/Modal";
import Tooltip from "components/Tooltip"; import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu"; import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu"; import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TemplatesMenu from "menus/TemplatesMenu"; import TemplatesMenu from "menus/TemplatesMenu";
import { metaDisplay } from "utils/keyboard"; import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers"; import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
type Props = { type Props = {|
auth: AuthStore,
ui: UiStore,
shares: SharesStore,
policies: PoliciesStore,
document: Document, document: Document,
isDraft: boolean, isDraft: boolean,
isEditing: boolean, isEditing: boolean,
@@ -56,356 +44,263 @@ type Props = {
publish?: boolean, publish?: boolean,
autosave?: boolean, autosave?: boolean,
}) => void, }) => void,
t: TFunction, |};
};
@observer function DocumentHeader({
class Header extends React.Component<Props> { document,
@observable isScrolled = false; isEditing,
@observable showShareModal = false; isDraft,
isPublishing,
isRevision,
isSaving,
savingIsDisabled,
publishingIsDisabled,
onSave,
}: Props) {
const { t } = useTranslation();
const { auth, ui, shares, policies } = useStores();
const [showShareModal, setShowShareModal] = React.useState(false);
componentDidMount() { const handleSave = React.useCallback(() => {
window.addEventListener("scroll", this.handleScroll); onSave({ done: true });
} }, [onSave]);
componentWillUnmount() { const handlePublish = React.useCallback(() => {
window.removeEventListener("scroll", this.handleScroll); onSave({ done: true, publish: true });
} }, [onSave]);
updateIsScrolled = () => { const handleShareLink = React.useCallback(
this.isScrolled = window.scrollY > 75; async (ev: SyntheticEvent<>) => {
}; await document.share();
handleScroll = throttle(this.updateIsScrolled, 50); setShowShareModal(true);
},
[document]
);
handleSave = () => { const handleCloseShareModal = React.useCallback(() => {
this.props.onSave({ done: true }); setShowShareModal(false);
}; }, []);
handlePublish = () => { const share = shares.getByDocumentId(document.id);
this.props.onSave({ done: true, publish: true }); 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<>) => { return (
const { document } = this.props; <>
await document.share(); <Modal
isOpen={showShareModal}
this.showShareModal = true; onRequestClose={handleCloseShareModal}
}; title={t("Share document")}
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}
> >
<Modal <DocumentShare document={document} onSubmit={handleCloseShareModal} />
isOpen={this.showShareModal} </Modal>
onRequestClose={this.handleCloseShareModal} <Header
title={t("Share document")} breadcrumb={
> <Breadcrumb document={document}>
<DocumentShare {!isEditing && (
document={document} <>
onSubmit={this.handleCloseShareModal} <Slash />
/> <Tooltip
</Modal> tooltip={
<Breadcrumb document={document}> ui.tocVisible ? t("Hide contents") : t("Show contents")
{!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
} }
icon={<TableOfContentsIcon />} shortcut={`ctrl+${metaDisplay}+h`}
iconColor="currentColor" delay={250}
borderOnHover placement="bottom"
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
> >
{t("Share")} <Button
</Button> onClick={
</Tooltip> ui.tocVisible
</Action> ? ui.hideTableOfContents
)} : ui.showTableOfContents
{isEditing && ( }
<> 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> <Action>
<Tooltip <Tooltip
tooltip={t("Save")} tooltip={
shortcut={`${metaDisplay}+enter`} isPubliclyShared ? (
<Trans>
Anyone with the link <br />
can view this document
</Trans>
) : (
""
)
}
delay={500} delay={500}
placement="bottom" placement="bottom"
> >
<Button <Button
onClick={this.handleSave} icon={isPubliclyShared ? <GlobeIcon /> : undefined}
disabled={savingIsDisabled} onClick={handleShareLink}
neutral={isDraft} neutral
> >
{isDraft ? t("Save Draft") : t("Done Editing")} {t("Share")}
</Button> </Button>
</Tooltip> </Tooltip>
</Action> </Action>
</> )}
)} {isEditing && (
{canEdit && ( <>
<Action> <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) => (
<Tooltip <Tooltip
tooltip={t("New document")} tooltip={t("Save")}
shortcut="n" shortcut={`${metaDisplay}+enter`}
delay={500} delay={500}
placement="bottom" placement="bottom"
> >
<Button icon={<PlusIcon />} {...props} neutral> <Button
{t("New doc")} onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
>
{isDraft ? t("Save Draft") : t("Done Editing")}
</Button> </Button>
</Tooltip> </Tooltip>
)} </Action>
/> </>
</Action> )}
)} {canEdit && (
{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> <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} document={document}
isRevision={isRevision}
label={(props) => ( label={(props) => (
<Button <Tooltip
icon={<MoreIcon />} tooltip={t("New document")}
iconColor="currentColor" shortcut="n"
{...props} delay={500}
borderOnHover placement="bottom"
neutral >
/> <Button icon={<PlusIcon />} {...props} neutral>
{t("New doc")}
</Button>
</Tooltip>
)} )}
showToggleEmbeds={canToggleEmbeds}
showPrint
/> />
</Action> </Action>
</> )}
)} {canEdit && isTemplate && !isDraft && !isRevision && (
</Wrapper> <Action>
</Actions> <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` const Status = styled.div`
color: ${(props) => props.theme.slate}; color: ${(props) => props.theme.slate};
`; `;
const Wrapper = styled(Flex)` export default observer(DocumentHeader);
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)
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,9 +55,9 @@ function UserProfile(props: Props) {
time: distanceInWordsToNow(new Date(user.createdAt)), time: distanceInWordsToNow(new Date(user.createdAt)),
})} })}
{user.isAdmin && ( {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 && ( {isCurrentUser && (
<Edit> <Edit>
<Button <Button

View File

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

View File

@@ -67,9 +67,9 @@
"@babel/preset-flow": "^7.10.4", "@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4", "@babel/preset-react": "^7.10.4",
"@rehooks/window-scroll-position": "^1.0.1", "@rehooks/window-scroll-position": "^1.0.1",
"@sentry/node": "^5.23.0", "@sentry/node": "^6.1.0",
"@sentry/react": "^6.0.1", "@sentry/react": "^6.1.0",
"@sentry/tracing": "^6.0.1", "@sentry/tracing": "^6.1.0",
"@tippy.js/react": "^2.2.2", "@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "0.3.1", "@tommoor/remove-markdown": "0.3.1",
"autotrack": "^2.4.1", "autotrack": "^2.4.1",
@@ -84,6 +84,7 @@
"copy-to-clipboard": "^3.0.6", "copy-to-clipboard": "^3.0.6",
"core-js": "2", "core-js": "2",
"date-fns": "1.29.0", "date-fns": "1.29.0",
"dd-trace": "^0.30.6",
"debug": "^4.1.1", "debug": "^4.1.1",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emoji-regex": "^6.5.1", "emoji-regex": "^6.5.1",
@@ -153,7 +154,7 @@
"react-waypoint": "^9.0.2", "react-waypoint": "^9.0.2",
"react-window": "^1.8.6", "react-window": "^1.8.6",
"reakit": "^1.3.4", "reakit": "^1.3.4",
"rich-markdown-editor": "^11.2.0-0", "rich-markdown-editor": "^11.3.0",
"semver": "^7.3.2", "semver": "^7.3.2",
"sequelize": "^6.3.4", "sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0", "sequelize-cli": "^6.2.0",
@@ -182,6 +183,7 @@
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^26.2.2", "babel-jest": "^26.2.2",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"eslint": "^7.6.0", "eslint": "^7.6.0",
"eslint-config-react-app": "3.0.6", "eslint-config-react-app": "3.0.6",
"eslint-plugin-flowtype": "^5.2.0", "eslint-plugin-flowtype": "^5.2.0",
@@ -204,11 +206,13 @@
"url-loader": "^0.6.2", "url-loader": "^0.6.2",
"webpack": "4.44.1", "webpack": "4.44.1",
"webpack-cli": "^3.3.12", "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": { "resolutions": {
"dot-prop": "^5.2.0", "dot-prop": "^5.2.0",
"js-yaml": "^3.13.1" "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": [ "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 // catch errors in one place, automatically set status and response headers
onerror(app); onerror(app);
if (process.env.SENTRY_DSN) { if (env.SENTRY_DSN) {
Sentry.init({ Sentry.init({
dsn: process.env.SENTRY_DSN, dsn: env.SENTRY_DSN,
environment: process.env.NODE_ENV, environment: env.ENVIRONMENT,
release: env.RELEASE,
maxBreadcrumbs: 0, maxBreadcrumbs: 0,
ignoreErrors: [ ignoreErrors: [
// emitted by Koa when bots attempt to snoop on paths such as wp-admin // emitted by Koa when bots attempt to snoop on paths such as wp-admin

View File

@@ -1,12 +1,17 @@
// @flow // @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 { export default {
URL: process.env.URL, URL: process.env.URL,
CDN_URL: process.env.CDN_URL || "", CDN_URL: process.env.CDN_URL || "",
DEPLOYMENT: process.env.DEPLOYMENT, DEPLOYMENT: process.env.DEPLOYMENT,
ENVIRONMENT: process.env.NODE_ENV,
SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_DSN: process.env.SENTRY_DSN,
TEAM_LOGO: process.env.TEAM_LOGO, TEAM_LOGO: process.env.TEAM_LOGO,
SLACK_KEY: process.env.SLACK_KEY, SLACK_KEY: process.env.SLACK_KEY,
SLACK_APP_ID: process.env.SLACK_APP_ID, SLACK_APP_ID: process.env.SLACK_APP_ID,
SUBDOMAINS_ENABLED: process.env.SUBDOMAINS_ENABLED === "true", SUBDOMAINS_ENABLED: process.env.SUBDOMAINS_ENABLED === "true",
GOOGLE_ANALYTICS_ID: process.env.GOOGLE_ANALYTICS_ID, 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 // @flow
require("dotenv").config({ silent: true }); 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 ( if (
!process.env.SECRET_KEY || !process.env.SECRET_KEY ||
process.env.SECRET_KEY === "generate_a_new_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") { if (process.env.NODE_ENV === "production") {
router.get("/static/*", async (ctx) => { router.get("/static/*", async (ctx) => {
ctx.set({ ctx.set({
"Service-Worker-Allowed": "/",
"Access-Control-Allow-Origin": "*",
"Cache-Control": `max-age=${356 * 24 * 60 * 60}`, "Cache-Control": `max-age=${356 * 24 * 60 * 60}`,
}); });

View File

@@ -2,8 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Outline</title> <title>Outline</title>
<meta name="theme-color" content="#FFF" />
<meta name="slack-app-id" content="//inject-slack-app-id//" /> <meta name="slack-app-id" content="//inject-slack-app-id//" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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…"> <meta name="description" content="A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…">
//inject-prefetch// //inject-prefetch//
<link <link
@@ -12,7 +15,12 @@
href="/favicon-32.png" href="/favicon-32.png"
sizes="32x32" 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 <link
rel="search" rel="search"
type="application/opensearchdescription+xml" type="application/opensearchdescription+xml"
@@ -46,7 +54,9 @@
</script> </script>
<script> <script>
if (window.localStorage && window.localStorage.getItem("theme") === "dark") { 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> </script>
</body> </body>

View File

@@ -17,6 +17,11 @@ const s3 = new AWS.S3({
accessKeyId: AWS_ACCESS_KEY_ID, accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY, secretAccessKey: AWS_SECRET_ACCESS_KEY,
region: AWS_REGION, 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", signatureVersion: "v4",
}); });
@@ -110,7 +115,6 @@ export const uploadToS3FromBuffer = async (
Key: key, Key: key,
ContentType: contentType, ContentType: contentType,
ContentLength: buffer.length, ContentLength: buffer.length,
ServerSideEncryption: "AES256",
Body: buffer, Body: buffer,
}) })
.promise(); .promise();
@@ -135,7 +139,6 @@ export const uploadToS3FromUrl = async (
Key: key, Key: key,
ContentType: res.headers["content-type"], ContentType: res.headers["content-type"],
ContentLength: res.headers["content-length"], ContentLength: res.headers["content-length"],
ServerSideEncryption: "AES256",
Body: buffer, Body: buffer,
}) })
.promise(); .promise();

View File

@@ -8,6 +8,10 @@
"Drafts": "Drafts", "Drafts": "Drafts",
"Templates": "Templates", "Templates": "Templates",
"Deleted Collection": "Deleted Collection", "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", "Submenu": "Submenu",
"New": "New", "New": "New",
"Only visible to you": "Only visible to you", "Only visible to you": "Only visible to you",
@@ -94,7 +98,7 @@
"Settings": "Settings", "Settings": "Settings",
"Invite people": "Invite people", "Invite people": "Invite people",
"Create a collection": "Create a collection", "Create a collection": "Create a collection",
"Return to App": "Return to App", "Return to App": "Back to App",
"Account": "Account", "Account": "Account",
"Profile": "Profile", "Profile": "Profile",
"Notifications": "Notifications", "Notifications": "Notifications",
@@ -108,8 +112,6 @@
"Import / Export": "Import / Export", "Import / Export": "Import / Export",
"Integrations": "Integrations", "Integrations": "Integrations",
"Installation": "Installation", "Installation": "Installation",
"Expand": "Expand",
"Collapse": "Collapse",
"Unstar": "Unstar", "Unstar": "Unstar",
"Star": "Star", "Star": "Star",
"Appearance": "Appearance", "Appearance": "Appearance",
@@ -204,8 +206,6 @@
"The collection was updated": "The collection was updated", "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.", "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", "Name": "Name",
"Description": "Description",
"More details about this collection…": "More details about this collection…",
"Alphabetical": "Alphabetical", "Alphabetical": "Alphabetical",
"Private collection": "Private collection", "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.", "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", "Never signed in": "Never signed in",
"Invited": "Invited", "Invited": "Invited",
"Admin": "Admin", "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", "Creating": "Creating",
"Create": "Create", "Create": "Create",
"Recently viewed": "Recently viewed",
"Created by me": "Created by me",
"Search documents": "Search documents",
"Hide contents": "Hide contents", "Hide contents": "Hide contents",
"Show contents": "Show contents", "Show contents": "Show contents",
"Archived": "Archived", "Archived": "Archived",
@@ -260,6 +257,7 @@
"Deleting": "Deleting", "Deleting": "Deleting",
"Im sure  Delete": "Im sure  Delete", "Im sure  Delete": "Im sure  Delete",
"Archiving": "Archiving", "Archiving": "Archiving",
"Search documents": "Search documents",
"No documents found for your filters.": "No documents found for your filters.", "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.", "Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.",
"Not found": "Not found", "Not found": "Not found",
@@ -275,6 +273,8 @@
"Could not remove user": "Could not remove user", "Could not remove user": "Could not remove user",
"Add people": "Add people", "Add people": "Add people",
"This group has no members.": "This group has no members.", "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.", "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", "Navigation": "Navigation",
"New document in current collection": "New document in current collection", "New document in current collection": "New document in current collection",

View File

@@ -37,6 +37,21 @@ export default createGlobalStyle`
text-rendering: optimizeLegibility; 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 { a {
color: ${(props) => props.theme.link}; color: ${(props) => props.theme.link};
text-decoration: none; text-decoration: none;

View File

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

View File

@@ -4,6 +4,8 @@ const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent'); const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent');
const pkg = require("rich-markdown-editor/package.json"); 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 }); require('dotenv').config({ silent: true });
@@ -59,6 +61,30 @@ module.exports = {
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
template: 'server/static/index.html', 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(), new RelativeCiAgentWebpackPlugin(),
], ],
stats: { stats: {

1629
yarn.lock

File diff suppressed because it is too large Load Diff